<?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>Design on Tarragon</title><link>https://tarrragon.github.io/blog/tags/design/</link><description>Recent content in Design on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/design/index.xml" rel="self" type="application/rss+xml"/><item><title>Gate 分類與三問設計法</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。&lt;/p>
&lt;h2 id="三問設計法">三問設計法&lt;/h2>
&lt;p>每個 gate 設計時回答三個問題：&lt;/p>
&lt;h3 id="成功時做什麼">成功時做什麼&lt;/h3>
&lt;p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。&lt;/p>
&lt;p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。&lt;/p>
&lt;h3 id="失敗時做什麼">失敗時做什麼&lt;/h3>
&lt;p>Gate 未通過時使用者的&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑&lt;/a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。&lt;/p>
&lt;p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 &lt;code>biometricOnly: true&lt;/code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。修復只改一個 boolean — &lt;code>biometricOnly: false&lt;/code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。&lt;/p>
&lt;h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼&lt;/h3>
&lt;p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。&lt;/p>
&lt;p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。&lt;/p>
&lt;p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。&lt;/p>
&lt;h2 id="gate-的四種常見類型">Gate 的四種常見類型&lt;/h2>
&lt;h3 id="認證-gate">認證 Gate&lt;/h3>
&lt;p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。&lt;/p>
&lt;p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h3 id="網路-gate">網路 Gate&lt;/h3>
&lt;p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。&lt;/p>
&lt;h3 id="權限-gate">權限 Gate&lt;/h3>
&lt;p>App 需要系統權限（相機、位置、通知）才能使用特定功能。&lt;/p>
&lt;p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。&lt;/p>
&lt;h3 id="環境-gate">環境 Gate&lt;/h3>
&lt;p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。&lt;/p>
&lt;p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。&lt;/p>
&lt;h3 id="其他常見-gate">其他常見 Gate&lt;/h3>
&lt;p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：&lt;/p>
&lt;p>&lt;strong>付費 Gate&lt;/strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。&lt;/p>
&lt;p>&lt;strong>版本相容性 Gate&lt;/strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。&lt;/p>
&lt;h2 id="gate-設計表">Gate 設計表&lt;/h2>
&lt;p>把三問設計法應用到每個 gate，產出一張設計表：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&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;/td>
 &lt;td>進入主畫面&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;td>顯示 loading + 取消&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相機權限&lt;/td>
 &lt;td>開啟掃描功能&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;td>顯示搜尋中 + 取消&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣&lt;/a>的退出路徑檢查同樣的邏輯。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。</p>
<h2 id="三問設計法">三問設計法</h2>
<p>每個 gate 設計時回答三個問題：</p>
<h3 id="成功時做什麼">成功時做什麼</h3>
<p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。</p>
<p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。</p>
<h3 id="失敗時做什麼">失敗時做什麼</h3>
<p>Gate 未通過時使用者的<a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑</a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。</p>
<p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 <code>biometricOnly: true</code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。修復只改一個 boolean — <code>biometricOnly: false</code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。</p>
<h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼</h3>
<p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。</p>
<p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。</p>
<p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。</p>
<h2 id="gate-的四種常見類型">Gate 的四種常見類型</h2>
<h3 id="認證-gate">認證 Gate</h3>
<p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。</p>
<p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h3 id="網路-gate">網路 Gate</h3>
<p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。</p>
<h3 id="權限-gate">權限 Gate</h3>
<p>App 需要系統權限（相機、位置、通知）才能使用特定功能。</p>
<p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。</p>
<h3 id="環境-gate">環境 Gate</h3>
<p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。</p>
<p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。</p>
<h3 id="其他常見-gate">其他常見 Gate</h3>
<p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：</p>
<p><strong>付費 Gate</strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。</p>
<p><strong>版本相容性 Gate</strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。</p>
<h2 id="gate-設計表">Gate 設計表</h2>
<p>把三問設計法應用到每個 gate，產出一張設計表：</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>成功</th>
          <th>失敗</th>
          <th>不確定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生物辨識</td>
          <td>進入主畫面</td>
          <td>提示輸入裝置密碼</td>
          <td>顯示「驗證中」</td>
      </tr>
      <tr>
          <td>網路連線</td>
          <td>開始載入資料</td>
          <td>顯示離線提示 + 重試</td>
          <td>顯示 loading + 取消</td>
      </tr>
      <tr>
          <td>相機權限</td>
          <td>開啟掃描功能</td>
          <td>說明原因 + 設定連結</td>
          <td>等待系統對話框</td>
      </tr>
      <tr>
          <td>藍牙</td>
          <td>開始裝置搜尋</td>
          <td>提示開啟藍牙 + 連結</td>
          <td>顯示搜尋中 + 取消</td>
      </tr>
  </tbody>
</table>
<p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的退出路徑檢查同樣的邏輯。</p>
<p>三問設計法的具體應用在 <a href="/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計</a>中以生物辨識 gate 為例展開。Gate 在開發環境的行為可能和真機不同，<a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a>列出每個 gate 在模擬器和真機上的差異。Gate 設計表的「失敗」欄和<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的「退出路徑」欄是同一個問題在不同層級的表達。</p>
]]></content:encoded></item><item><title>三層 log 設計</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</guid><description>&lt;p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。&lt;/p>
&lt;h2 id="連線生命週期-log">連線生命週期 log&lt;/h2>
&lt;p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。&lt;/p>
&lt;p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。&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">[conn] Step 1/5: biometric auth completed (duration: 320ms)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[conn] Step 2/5: credential loaded (user: admin)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[conn] Step 4/5: auth token sent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">[conn] Step 5/5: stream subscribed, ready&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。&lt;/p>
&lt;p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。&lt;/p>
&lt;h2 id="protocol-訊息-log">Protocol 訊息 log&lt;/h2>
&lt;p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。&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">[proto] TX: text frame, payload: {&amp;#34;AuthToken&amp;#34;:&amp;#34;base64...&amp;#34;} (42 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[proto] RX: text frame, payload prefix: &amp;#34;0&amp;#34; (output data, 128 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/binary frame 問題（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）如果有 protocol log，開發者會在 log 中看到 &lt;code>TX: binary frame&lt;/code> 而非預期的 &lt;code>TX: text frame&lt;/code> — 直接指向 frame type 問題。&lt;/p></description><content:encoded><![CDATA[<p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。</p>
<h2 id="連線生命週期-log">連線生命週期 log</h2>
<p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。</p>
<p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。</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">[conn] Step 1/5: biometric auth completed (duration: 320ms)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[conn] Step 2/5: credential loaded (user: admin)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
</span></span><span class="line"><span class="ln">4</span><span class="cl">[conn] Step 4/5: auth token sent
</span></span><span class="line"><span class="ln">5</span><span class="cl">[conn] Step 5/5: stream subscribed, ready</span></span></code></pre></div><p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（<a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。</p>
<p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。</p>
<h2 id="protocol-訊息-log">Protocol 訊息 log</h2>
<p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。</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">[proto] TX: text frame, payload: {&#34;AuthToken&#34;:&#34;base64...&#34;} (42 bytes)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[proto] RX: text frame, payload prefix: &#34;0&#34; (output data, 128 bytes)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)</span></span></code></pre></div><p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/binary frame 問題（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）如果有 protocol log，開發者會在 log 中看到 <code>TX: binary frame</code> 而非預期的 <code>TX: text frame</code> — 直接指向 frame type 問題。</p>
<p>Protocol log 在 release mode 應該能關閉。這層 log 量大（每次鍵盤輸入一條），且 payload 可能包含敏感資訊。Debug mode 預設啟用，release mode 提供開關（例如隱藏設定頁的 toggle）讓進階使用者在回報問題時開啟。</p>
<h2 id="使用者行為-log">使用者行為 log</h2>
<p>使用者行為 log 記錄的是使用者在 UI 上的操作：按鈕點擊、畫面切換、設定變更。這層 log 的粒度是操作級 — 使用者做了一個有意義的動作記一條。</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">[ui] screen: HomeScreen, action: tap Connect Terminal
</span></span><span class="line"><span class="ln">2</span><span class="cl">[ui] screen: TerminalScreen, state: connecting → connected
</span></span><span class="line"><span class="ln">3</span><span class="cl">[ui] screen: TerminalScreen, action: tap back button
</span></span><span class="line"><span class="ln">4</span><span class="cl">[ui] screen: HomeScreen, state: returned from terminal</span></span></code></pre></div><p>使用者行為 log 在兩個場景有價值：第一，debug 時還原使用者操作路徑 — 「使用者做了什麼導致問題出現」；第二，結合狀態矩陣（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）做狀態轉換的實際覆蓋率分析 — 哪些狀態轉換在真實使用中經常發生，哪些從未發生。</p>
<p>使用者行為 log 在 release mode 啟用時需要注意隱私。記錄「使用者切換了畫面」是合理的；記錄「使用者輸入了密碼 abc123」需要 redaction 機制（<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>）。</p>
<h2 id="三層的關係">三層的關係</h2>
<p>三層 log 各自獨立運作，debug 時通常按照從粗到細的順序使用。</p>
<p><strong>粗篩</strong>：先看連線生命週期 log，確認流程走到哪一步。如果 Step 3 失敗，問題在 WebSocket 連線層。</p>
<p><strong>細查</strong>：切到 protocol 訊息 log，看 Step 3 的連線嘗試中發送和接收了什麼。如果看到 binary frame 發送但沒有回應，問題可能在 frame type。</p>
<p><strong>還原</strong>：如果問題和使用者操作有關（例如只在特定操作順序下觸發），看使用者行為 log，還原操作路徑。</p>
<p>三層 log 用同一個時間戳和 correlation ID（例如連線 session ID），讓跨層比對可行。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>在功能規格中定義 log 點 → <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a></li>
<li>事後補 log 和設計產物 log 的品質差異 → <a href="/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異</a></li>
<li>Log 收集方案選擇 → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
<li>事件分類與收集策略 → <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 監控心智模型</a></li>
</ul>
]]></content:encoded></item><item><title>功能規格中的 log 點定義方法</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/</guid><description>&lt;p>Log 點定義是功能規格的一部分，和 API schema 同級。功能規格描述「這個功能做什麼」，log 點規格描述「這個功能執行時留下什麼可觀察的紀錄」。把 log 點設計前移到規格階段，讓 log 成為功能的設計產物，而非事後的 debug 工具（本章合成，TF-9 Derive）。&lt;/p>
&lt;h2 id="四類-log-點">四類 log 點&lt;/h2>
&lt;p>每個功能的 log 點按執行時機分成四類。&lt;/p>
&lt;h3 id="啟動-log">啟動 log&lt;/h3>
&lt;p>功能開始執行時記錄。回答「這個功能是否被觸發了」。&lt;/p>
&lt;p>啟動 log 包含觸發來源（使用者操作、系統排程、外部事件）和初始參數（連線目標、操作類型）。如果一個功能從未被觸發，啟動 log 的缺席就是線索。&lt;/p>
&lt;h3 id="步驟-log">步驟 log&lt;/h3>
&lt;p>功能執行過程中的每個關鍵步驟完成時記錄。回答「流程走到哪裡了」。&lt;/p>
&lt;p>步驟 log 的粒度依功能複雜度而定。三步驟的功能每步記一條；十步驟的功能可以只記關鍵的三到五步。判斷標準是：如果這一步失敗，開發者是否需要知道失敗點在哪。&lt;/p>
&lt;h3 id="錯誤-log">錯誤 log&lt;/h3>
&lt;p>步驟失敗、例外捕獲、非預期狀態出現時記錄。回答「出了什麼問題」。&lt;/p>
&lt;p>錯誤 log 必須包含足夠的 context 讓開發者不需要重現問題就能判斷原因。至少包含：哪一步失敗、失敗原因（error message）、當時的關鍵狀態值。&lt;/p>
&lt;h3 id="完成-log">完成 log&lt;/h3>
&lt;p>功能正常結束時記錄。回答「功能是否成功完成、花了多久」。&lt;/p>
&lt;p>完成 log 包含執行結果和耗時。和啟動 log 配對使用 — 有啟動但沒有完成代表功能中途異常退出。&lt;/p>
&lt;h2 id="在功能規格中加可觀測性欄位">在功能規格中加可觀測性欄位&lt;/h2>
&lt;p>以 app_tunnel 的「連線到 ttyd 終端機」功能為例，傳統規格只寫：&lt;/p>
&lt;ul>
&lt;li>輸入：使用者選擇的伺服器&lt;/li>
&lt;li>處理：建立 WebSocket 連線、發送 auth token、開始接收 terminal output&lt;/li>
&lt;li>輸出：終端機畫面顯示 terminal output&lt;/li>
&lt;/ul>
&lt;p>加上可觀測性欄位後：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>log 點&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>啟動&lt;/td>
 &lt;td>connect.start&lt;/td>
 &lt;td>目標 URL、觸發來源（使用者操作 / 自動重連）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.biometric.done&lt;/td>
 &lt;td>認證結果、耗時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.credential.loaded&lt;/td>
 &lt;td>使用者名稱（密碼 redact）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.ws.connected&lt;/td>
 &lt;td>連線 URL、耗時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.auth.sent&lt;/td>
 &lt;td>token 長度（內容 redact）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.stream.subscribed&lt;/td>
 &lt;td>stream 狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤&lt;/td>
 &lt;td>connect.{step}.failed&lt;/td>
 &lt;td>失敗步驟、error message、retry count&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>完成&lt;/td>
 &lt;td>connect.done&lt;/td>
 &lt;td>總耗時、最終狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表在功能規格階段就能寫出來，因為它只依賴功能的流程設計，不依賴實作細節。功能流程確定後，每一步在哪裡需要 log 點就確定了。&lt;/p>
&lt;h2 id="log-點命名規則">log 點命名規則&lt;/h2>
&lt;p>統一的命名規則讓 log 可以被 grep、過濾和統計。&lt;/p>
&lt;p>&lt;strong>階層式命名&lt;/strong>：&lt;code>{功能}.{步驟}.{事件}&lt;/code>。例如 &lt;code>connect.ws.connected&lt;/code>、&lt;code>connect.auth.failed&lt;/code>。&lt;/p>
&lt;p>&lt;strong>事件後綴統一&lt;/strong>：&lt;code>start&lt;/code>（啟動）、&lt;code>done&lt;/code>（步驟完成）、&lt;code>failed&lt;/code>（失敗）、&lt;code>complete&lt;/code>（功能完成）。&lt;/p>
&lt;p>&lt;strong>和程式碼結構對應&lt;/strong>：log 點名稱對應到程式碼中的函式或模組。&lt;code>connect.biometric.done&lt;/code> 對應 &lt;code>BiometricService.authenticate()&lt;/code> 的成功路徑。這讓開發者看到 log 名稱就知道去哪裡找程式碼。&lt;/p>
&lt;h2 id="log-點規格的-review-檢查">log 點規格的 review 檢查&lt;/h2>
&lt;p>功能規格 review 時，可觀測性欄位的檢查要點：&lt;/p>
&lt;p>&lt;strong>每步都有 log&lt;/strong>：流程中的每個步驟在成功和失敗時都有對應的 log 點。遺漏的步驟意味著該步驟出問題時無法從 log 判斷。&lt;/p>
&lt;p>&lt;strong>錯誤 log 有足夠 context&lt;/strong>：error log 只寫「連線失敗」不夠；需要寫「連線失敗」加上 error code、目標 URL、已完成的步驟。&lt;/p>
&lt;p>&lt;strong>敏感欄位有 redaction 標記&lt;/strong>：密碼、token、個人資料在 log 規格中標記為 redact，實作時用 redaction 機制處理。&lt;/p>
&lt;p>&lt;strong>啟動和完成配對&lt;/strong>：每個功能有啟動 log 就應該有完成 log，形成完整的生命週期。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>三層 log 的詳細設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>事後補 log 和設計產物 log 的差異 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異&lt;/a>&lt;/li>
&lt;li>Log 中的敏感資訊處理 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Log 點定義是功能規格的一部分，和 API schema 同級。功能規格描述「這個功能做什麼」，log 點規格描述「這個功能執行時留下什麼可觀察的紀錄」。把 log 點設計前移到規格階段，讓 log 成為功能的設計產物，而非事後的 debug 工具（本章合成，TF-9 Derive）。</p>
<h2 id="四類-log-點">四類 log 點</h2>
<p>每個功能的 log 點按執行時機分成四類。</p>
<h3 id="啟動-log">啟動 log</h3>
<p>功能開始執行時記錄。回答「這個功能是否被觸發了」。</p>
<p>啟動 log 包含觸發來源（使用者操作、系統排程、外部事件）和初始參數（連線目標、操作類型）。如果一個功能從未被觸發，啟動 log 的缺席就是線索。</p>
<h3 id="步驟-log">步驟 log</h3>
<p>功能執行過程中的每個關鍵步驟完成時記錄。回答「流程走到哪裡了」。</p>
<p>步驟 log 的粒度依功能複雜度而定。三步驟的功能每步記一條；十步驟的功能可以只記關鍵的三到五步。判斷標準是：如果這一步失敗，開發者是否需要知道失敗點在哪。</p>
<h3 id="錯誤-log">錯誤 log</h3>
<p>步驟失敗、例外捕獲、非預期狀態出現時記錄。回答「出了什麼問題」。</p>
<p>錯誤 log 必須包含足夠的 context 讓開發者不需要重現問題就能判斷原因。至少包含：哪一步失敗、失敗原因（error message）、當時的關鍵狀態值。</p>
<h3 id="完成-log">完成 log</h3>
<p>功能正常結束時記錄。回答「功能是否成功完成、花了多久」。</p>
<p>完成 log 包含執行結果和耗時。和啟動 log 配對使用 — 有啟動但沒有完成代表功能中途異常退出。</p>
<h2 id="在功能規格中加可觀測性欄位">在功能規格中加可觀測性欄位</h2>
<p>以 app_tunnel 的「連線到 ttyd 終端機」功能為例，傳統規格只寫：</p>
<ul>
<li>輸入：使用者選擇的伺服器</li>
<li>處理：建立 WebSocket 連線、發送 auth token、開始接收 terminal output</li>
<li>輸出：終端機畫面顯示 terminal output</li>
</ul>
<p>加上可觀測性欄位後：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>log 點</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>啟動</td>
          <td>connect.start</td>
          <td>目標 URL、觸發來源（使用者操作 / 自動重連）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.biometric.done</td>
          <td>認證結果、耗時</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.credential.loaded</td>
          <td>使用者名稱（密碼 redact）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.ws.connected</td>
          <td>連線 URL、耗時</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.auth.sent</td>
          <td>token 長度（內容 redact）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.stream.subscribed</td>
          <td>stream 狀態</td>
      </tr>
      <tr>
          <td>錯誤</td>
          <td>connect.{step}.failed</td>
          <td>失敗步驟、error message、retry count</td>
      </tr>
      <tr>
          <td>完成</td>
          <td>connect.done</td>
          <td>總耗時、最終狀態</td>
      </tr>
  </tbody>
</table>
<p>這張表在功能規格階段就能寫出來，因為它只依賴功能的流程設計，不依賴實作細節。功能流程確定後，每一步在哪裡需要 log 點就確定了。</p>
<h2 id="log-點命名規則">log 點命名規則</h2>
<p>統一的命名規則讓 log 可以被 grep、過濾和統計。</p>
<p><strong>階層式命名</strong>：<code>{功能}.{步驟}.{事件}</code>。例如 <code>connect.ws.connected</code>、<code>connect.auth.failed</code>。</p>
<p><strong>事件後綴統一</strong>：<code>start</code>（啟動）、<code>done</code>（步驟完成）、<code>failed</code>（失敗）、<code>complete</code>（功能完成）。</p>
<p><strong>和程式碼結構對應</strong>：log 點名稱對應到程式碼中的函式或模組。<code>connect.biometric.done</code> 對應 <code>BiometricService.authenticate()</code> 的成功路徑。這讓開發者看到 log 名稱就知道去哪裡找程式碼。</p>
<h2 id="log-點規格的-review-檢查">log 點規格的 review 檢查</h2>
<p>功能規格 review 時，可觀測性欄位的檢查要點：</p>
<p><strong>每步都有 log</strong>：流程中的每個步驟在成功和失敗時都有對應的 log 點。遺漏的步驟意味著該步驟出問題時無法從 log 判斷。</p>
<p><strong>錯誤 log 有足夠 context</strong>：error log 只寫「連線失敗」不夠；需要寫「連線失敗」加上 error code、目標 URL、已完成的步驟。</p>
<p><strong>敏感欄位有 redaction 標記</strong>：密碼、token、個人資料在 log 規格中標記為 redact，實作時用 redaction 機制處理。</p>
<p><strong>啟動和完成配對</strong>：每個功能有啟動 log 就應該有完成 log，形成完整的生命週期。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>三層 log 的詳細設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>事後補 log 和設計產物 log 的差異 → <a href="/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異</a></li>
<li>Log 中的敏感資訊處理 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
</ul>
]]></content:encoded></item><item><title>欄位設計原則</title><link>https://tarrragon.github.io/blog/monitoring/02-log-schema/field-design-principles/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/02-log-schema/field-design-principles/</guid><description>&lt;p>事件 schema 的欄位設計遵循三個原則：來源可追溯、擴展不破壞、版本可辨識。這三個原則讓 schema 從自用工具的 grep 查詢一直到商業方案的資料管線都能正常運作。&lt;/p>
&lt;h2 id="原則一source-標明來源">原則一：source 標明來源&lt;/h2>
&lt;p>每筆事件的 source 欄位記錄「這筆事件從哪裡來」。App 名稱、版本、平台、OS 版本 — 這些資訊在事件產生時由 SDK 自動填入，不依賴使用者或開發者手動標記。&lt;/p>
&lt;p>source 的設計要點是「足夠區分但不過度」。&lt;code>sdk&lt;/code> 和 &lt;code>platform&lt;/code> 是必填——sdk 標明事件由哪個 SDK 實作產生（&lt;code>js&lt;/code> / &lt;code>flutter&lt;/code> / &lt;code>python&lt;/code> / &lt;code>go&lt;/code>），platform 標明運行平台（&lt;code>ios&lt;/code> / &lt;code>android&lt;/code> / &lt;code>web&lt;/code> / &lt;code>macos&lt;/code>）。兩者不能互相推導：同一個 platform（iOS）上可能有不同的 SDK（Flutter SDK 或 Swift 原生 SDK），同一個 SDK（Flutter）可能跑在不同 platform（iOS / Android / Web）。App 名稱和版本能區分「這是哪個 app 的哪個版本送來的事件」。OS 版本用於分析平台特定的問題（「這個 error 只出現在 iOS 17.4」）。&lt;/p>
&lt;p>不需要在 source 放裝置 ID 或使用者 ID — 這些屬於個人識別資訊，放在 source 會讓每一筆事件都攜帶 PII，增加去識別化的複雜度。Session ID 用於關聯同次使用的事件，已足夠取代裝置/使用者級別的追蹤。&lt;/p>
&lt;h2 id="原則二data-自由欄位">原則二：data 自由欄位&lt;/h2>
&lt;p>data 欄位是事件的附加資料區域，接受任意 JSON object。核心欄位（type、name、timestamp、source）有固定的 schema 驗證，data 的內容不做 schema 驗證（或做寬鬆驗證）。&lt;/p>
&lt;p>自由欄位的設計理由是「不同事件需要不同的附加資料」。&lt;code>terminal.connect.done&lt;/code> 需要 URL 和 duration；&lt;code>auth.biometric.failed&lt;/code> 需要 error code 和 fallback 方式。為每種事件定義固定的 data schema 會讓 schema 膨脹且頻繁變動。&lt;/p>
&lt;p>自由的代價是查詢時無法保證 data 內某個欄位一定存在。處理策略：查詢時用 optional access（&lt;code>data?.duration_ms&lt;/code>），統計時跳過缺少目標欄位的事件。&lt;/p>
&lt;h2 id="原則三v-版本演進">原則三：v 版本演進&lt;/h2>
&lt;p>v 欄位是整數版本號，標明「這筆事件是用哪個版本的 schema 產生的」。&lt;/p>
&lt;p>版本號解決的問題是 schema 變更時的向後相容。新版本的 SDK 產生 v=2 的事件，舊版本的 SDK 仍在產生 v=1 的事件。Collector 收到事件時根據 v 決定用哪個版本的驗證和處理邏輯。&lt;/p>
&lt;p>版本號的遞增規則：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>新增選填欄位&lt;/strong>：不需要遞增版本號。舊版事件缺少新欄位，collector 用預設值處理。&lt;/li>
&lt;li>&lt;strong>新增必填欄位&lt;/strong>：遞增版本號。舊版事件沒有這個欄位，collector 需要區分版本處理。&lt;/li>
&lt;li>&lt;strong>刪除或改名欄位&lt;/strong>：遞增版本號。collector 需要同時支援新舊版本的事件格式。&lt;/li>
&lt;li>&lt;strong>改變欄位型別&lt;/strong>：遞增版本號。string 改成 integer 等型別變更需要不同的解析邏輯。&lt;/li>
&lt;/ul>
&lt;h2 id="欄位命名慣例">欄位命名慣例&lt;/h2>
&lt;p>欄位名稱使用 snake_case（&lt;code>duration_ms&lt;/code>、&lt;code>error_code&lt;/code>），和 JSON 的慣例一致。避免在欄位名稱中編碼單位（&lt;code>duration&lt;/code> 不夠明確 — 是秒還是毫秒？），在名稱中加上單位後綴（&lt;code>duration_ms&lt;/code>、&lt;code>size_bytes&lt;/code>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>完整欄位定義 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說&lt;/a>&lt;/li>
&lt;li>Schema 版本演進的具體策略 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/schema-versioning/" data-link-title="Schema 版本演進策略" data-link-desc="Backward compatible 的增量變更 — 新增欄位不改版、改名或改型別才改版、collector 同時支援多版本">Schema 版本演進策略&lt;/a>&lt;/li>
&lt;li>和 OpenTelemetry 的比較 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>事件 schema 的欄位設計遵循三個原則：來源可追溯、擴展不破壞、版本可辨識。這三個原則讓 schema 從自用工具的 grep 查詢一直到商業方案的資料管線都能正常運作。</p>
<h2 id="原則一source-標明來源">原則一：source 標明來源</h2>
<p>每筆事件的 source 欄位記錄「這筆事件從哪裡來」。App 名稱、版本、平台、OS 版本 — 這些資訊在事件產生時由 SDK 自動填入，不依賴使用者或開發者手動標記。</p>
<p>source 的設計要點是「足夠區分但不過度」。<code>sdk</code> 和 <code>platform</code> 是必填——sdk 標明事件由哪個 SDK 實作產生（<code>js</code> / <code>flutter</code> / <code>python</code> / <code>go</code>），platform 標明運行平台（<code>ios</code> / <code>android</code> / <code>web</code> / <code>macos</code>）。兩者不能互相推導：同一個 platform（iOS）上可能有不同的 SDK（Flutter SDK 或 Swift 原生 SDK），同一個 SDK（Flutter）可能跑在不同 platform（iOS / Android / Web）。App 名稱和版本能區分「這是哪個 app 的哪個版本送來的事件」。OS 版本用於分析平台特定的問題（「這個 error 只出現在 iOS 17.4」）。</p>
<p>不需要在 source 放裝置 ID 或使用者 ID — 這些屬於個人識別資訊，放在 source 會讓每一筆事件都攜帶 PII，增加去識別化的複雜度。Session ID 用於關聯同次使用的事件，已足夠取代裝置/使用者級別的追蹤。</p>
<h2 id="原則二data-自由欄位">原則二：data 自由欄位</h2>
<p>data 欄位是事件的附加資料區域，接受任意 JSON object。核心欄位（type、name、timestamp、source）有固定的 schema 驗證，data 的內容不做 schema 驗證（或做寬鬆驗證）。</p>
<p>自由欄位的設計理由是「不同事件需要不同的附加資料」。<code>terminal.connect.done</code> 需要 URL 和 duration；<code>auth.biometric.failed</code> 需要 error code 和 fallback 方式。為每種事件定義固定的 data schema 會讓 schema 膨脹且頻繁變動。</p>
<p>自由的代價是查詢時無法保證 data 內某個欄位一定存在。處理策略：查詢時用 optional access（<code>data?.duration_ms</code>），統計時跳過缺少目標欄位的事件。</p>
<h2 id="原則三v-版本演進">原則三：v 版本演進</h2>
<p>v 欄位是整數版本號，標明「這筆事件是用哪個版本的 schema 產生的」。</p>
<p>版本號解決的問題是 schema 變更時的向後相容。新版本的 SDK 產生 v=2 的事件，舊版本的 SDK 仍在產生 v=1 的事件。Collector 收到事件時根據 v 決定用哪個版本的驗證和處理邏輯。</p>
<p>版本號的遞增規則：</p>
<ul>
<li><strong>新增選填欄位</strong>：不需要遞增版本號。舊版事件缺少新欄位，collector 用預設值處理。</li>
<li><strong>新增必填欄位</strong>：遞增版本號。舊版事件沒有這個欄位，collector 需要區分版本處理。</li>
<li><strong>刪除或改名欄位</strong>：遞增版本號。collector 需要同時支援新舊版本的事件格式。</li>
<li><strong>改變欄位型別</strong>：遞增版本號。string 改成 integer 等型別變更需要不同的解析邏輯。</li>
</ul>
<h2 id="欄位命名慣例">欄位命名慣例</h2>
<p>欄位名稱使用 snake_case（<code>duration_ms</code>、<code>error_code</code>），和 JSON 的慣例一致。避免在欄位名稱中編碼單位（<code>duration</code> 不夠明確 — 是秒還是毫秒？），在名稱中加上單位後綴（<code>duration_ms</code>、<code>size_bytes</code>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>完整欄位定義 → <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json 完整欄位解說</a></li>
<li>Schema 版本演進的具體策略 → <a href="/blog/monitoring/02-log-schema/schema-versioning/" data-link-title="Schema 版本演進策略" data-link-desc="Backward compatible 的增量變更 — 新增欄位不改版、改名或改型別才改版、collector 同時支援多版本">Schema 版本演進策略</a></li>
<li>和 OpenTelemetry 的比較 → <a href="/blog/monitoring/02-log-schema/otel-comparison/" data-link-title="跟 OpenTelemetry 的 schema 差異對照" data-link-desc="自架 event schema 和 OTLP 的設計差異 — 為什麼 client-side 監控用簡化 schema、什麼時候切換到 OTLP">跟 OpenTelemetry 的 schema 差異對照</a></li>
</ul>
]]></content:encoded></item><item><title>「事後補 log」vs「設計產物 log」的品質差異</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/</guid><description>&lt;p>事後補 log 和設計產物 log 的差別在於產出時機和品質標準。事後補的 log 在 debug 壓力下產出，目的是「讓這次的問題能被定位」；設計產物的 log 在功能規格階段產出，目的是「讓未來任何問題都能被定位」。兩者的品質差異在格式統一性、覆蓋完整性和長期維護成本三個面向上表現明顯。&lt;/p>
&lt;h2 id="格式統一性">格式統一性&lt;/h2>
&lt;p>app_tunnel 在 W2 修復時補的 &lt;code>developer.log&lt;/code> 格式不統一（&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>）。不同元件由不同時間點、不同 debug 需求補上的 log，各自有各自的風格：&lt;/p>
&lt;p>有的帶 &lt;code>name:&lt;/code> 參數讓 log 可以按元件過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">developer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;WS connected&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">name:&lt;/span> &lt;span class="s1">&amp;#39;ConnectionManager&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>有的不帶，混在全域 log 裡無法過濾：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">developer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;auth token sent&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>有的帶 &lt;code>// i18n-exempt&lt;/code> 標記（因為 linter 會對 hardcoded string 報警），有的忘了加。有的把錯誤訊息放在 &lt;code>error:&lt;/code> 參數，有的用字串串接。&lt;/p>
&lt;p>這些不一致來自事後補 log 的結構性原因：每條 log 是在解決當下問題時加的，沒有統一規範，也沒有 review。加完能定位問題就提交，下次遇到新問題再加新的 log — 格式隨機。&lt;/p>
&lt;p>設計產物 log 在產出前就有命名規則和格式規範（見 &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法&lt;/a>）。所有 log 點走同一個 &lt;code>AppLogger&lt;/code> 介面，name、level、結構化欄位在規格階段就定義好，實作時照規格寫。&lt;/p>
&lt;h2 id="覆蓋完整性">覆蓋完整性&lt;/h2>
&lt;p>事後補 log 的覆蓋範圍由「哪些問題已經發生過」決定。W2-002 auth token 問題觸發了 &lt;code>ConnectionManager&lt;/code> 和 &lt;code>TerminalScreen&lt;/code> 的 log 補充，但 &lt;code>TtydProtocol&lt;/code>、&lt;code>BiometricService&lt;/code>、&lt;code>CredentialRepository&lt;/code>、&lt;code>EnrollmentScreen&lt;/code> 四個元件仍然零 log — 因為這四個元件在 W2 的 debug 過程中不是瓶頸。&lt;/p>
&lt;p>六個核心元件中四個零 log 的狀態意味著：下次如果問題出在 &lt;code>BiometricService&lt;/code>（例如特定 iOS 版本的 biometric API 行為改變），debug 又會回到「手動加 log → 重新編譯 → 插拔裝置」的循環。事後補 log 只覆蓋已知問題的路徑，對未知問題沒有防護。&lt;/p>
&lt;p>設計產物 log 的覆蓋範圍由功能流程的步驟數決定。每個功能規格列出所有步驟的 log 點，不管這些步驟是否曾經出過問題。&lt;code>BiometricService.authenticate()&lt;/code> 在規格中就有 start/done/failed 三個 log 點，無論是否遇過 biometric 問題。&lt;/p>
&lt;h2 id="維護成本">維護成本&lt;/h2>
&lt;p>事後補 log 隨 debug 過程累積，沒有統一管理。隨時間推移：&lt;/p>
&lt;ul>
&lt;li>某些 log 的觸發條件已經不存在了（被修復的 bug 對應的 log），但沒人清理&lt;/li>
&lt;li>某些 log 的格式和新加的 log 不一致，但沒人統一&lt;/li>
&lt;li>某些 log 的 context 資訊不足（當時能定位問題是因為開發者記得 context，半年後換人接手就不夠了）&lt;/li>
&lt;li>某些 log 在 release build 中不該出現但忘了加條件&lt;/li>
&lt;/ul>
&lt;p>設計產物 log 有規格文件作為 source of truth。功能變更時更新規格中的 log 點列表，刪除的步驟對應的 log 點一起刪除，新增的步驟對應的 log 點一起新增。Log 的生命週期和功能的生命週期綁定。&lt;/p>
&lt;h2 id="從事後補過渡到設計產物">從事後補過渡到設計產物&lt;/h2>
&lt;p>已有的事後補 log 不需要全部重寫。過渡策略是：&lt;/p>
&lt;p>&lt;strong>統一入口&lt;/strong>：建立 &lt;code>AppLogger&lt;/code> 封裝，把現有的 &lt;code>developer.log&lt;/code> 呼叫改為走 &lt;code>AppLogger&lt;/code>。這一步不改 log 內容，只改呼叫方式，讓後續的格式統一和功能切換有統一入口。&lt;/p></description><content:encoded><![CDATA[<p>事後補 log 和設計產物 log 的差別在於產出時機和品質標準。事後補的 log 在 debug 壓力下產出，目的是「讓這次的問題能被定位」；設計產物的 log 在功能規格階段產出，目的是「讓未來任何問題都能被定位」。兩者的品質差異在格式統一性、覆蓋完整性和長期維護成本三個面向上表現明顯。</p>
<h2 id="格式統一性">格式統一性</h2>
<p>app_tunnel 在 W2 修復時補的 <code>developer.log</code> 格式不統一（<a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>）。不同元件由不同時間點、不同 debug 需求補上的 log，各自有各自的風格：</p>
<p>有的帶 <code>name:</code> 參數讓 log 可以按元件過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">developer</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="s1">&#39;WS connected&#39;</span><span class="p">,</span> <span class="nl">name:</span> <span class="s1">&#39;ConnectionManager&#39;</span><span class="p">);</span></span></span></code></pre></div><p>有的不帶，混在全域 log 裡無法過濾：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">developer</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="s1">&#39;auth token sent&#39;</span><span class="p">);</span></span></span></code></pre></div><p>有的帶 <code>// i18n-exempt</code> 標記（因為 linter 會對 hardcoded string 報警），有的忘了加。有的把錯誤訊息放在 <code>error:</code> 參數，有的用字串串接。</p>
<p>這些不一致來自事後補 log 的結構性原因：每條 log 是在解決當下問題時加的，沒有統一規範，也沒有 review。加完能定位問題就提交，下次遇到新問題再加新的 log — 格式隨機。</p>
<p>設計產物 log 在產出前就有命名規則和格式規範（見 <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a>）。所有 log 點走同一個 <code>AppLogger</code> 介面，name、level、結構化欄位在規格階段就定義好，實作時照規格寫。</p>
<h2 id="覆蓋完整性">覆蓋完整性</h2>
<p>事後補 log 的覆蓋範圍由「哪些問題已經發生過」決定。W2-002 auth token 問題觸發了 <code>ConnectionManager</code> 和 <code>TerminalScreen</code> 的 log 補充，但 <code>TtydProtocol</code>、<code>BiometricService</code>、<code>CredentialRepository</code>、<code>EnrollmentScreen</code> 四個元件仍然零 log — 因為這四個元件在 W2 的 debug 過程中不是瓶頸。</p>
<p>六個核心元件中四個零 log 的狀態意味著：下次如果問題出在 <code>BiometricService</code>（例如特定 iOS 版本的 biometric API 行為改變），debug 又會回到「手動加 log → 重新編譯 → 插拔裝置」的循環。事後補 log 只覆蓋已知問題的路徑，對未知問題沒有防護。</p>
<p>設計產物 log 的覆蓋範圍由功能流程的步驟數決定。每個功能規格列出所有步驟的 log 點，不管這些步驟是否曾經出過問題。<code>BiometricService.authenticate()</code> 在規格中就有 start/done/failed 三個 log 點，無論是否遇過 biometric 問題。</p>
<h2 id="維護成本">維護成本</h2>
<p>事後補 log 隨 debug 過程累積，沒有統一管理。隨時間推移：</p>
<ul>
<li>某些 log 的觸發條件已經不存在了（被修復的 bug 對應的 log），但沒人清理</li>
<li>某些 log 的格式和新加的 log 不一致，但沒人統一</li>
<li>某些 log 的 context 資訊不足（當時能定位問題是因為開發者記得 context，半年後換人接手就不夠了）</li>
<li>某些 log 在 release build 中不該出現但忘了加條件</li>
</ul>
<p>設計產物 log 有規格文件作為 source of truth。功能變更時更新規格中的 log 點列表，刪除的步驟對應的 log 點一起刪除，新增的步驟對應的 log 點一起新增。Log 的生命週期和功能的生命週期綁定。</p>
<h2 id="從事後補過渡到設計產物">從事後補過渡到設計產物</h2>
<p>已有的事後補 log 不需要全部重寫。過渡策略是：</p>
<p><strong>統一入口</strong>：建立 <code>AppLogger</code> 封裝，把現有的 <code>developer.log</code> 呼叫改為走 <code>AppLogger</code>。這一步不改 log 內容，只改呼叫方式，讓後續的格式統一和功能切換有統一入口。</p>
<p><strong>補規格</strong>：對每個功能寫出 log 點規格表（四類 log 點），比對現有 log 和規格的差距。規格中有但程式碼中沒有的 log 點 = 覆蓋缺口，補上。程式碼中有但規格中沒有的 log 點 = 可能是過時的 debug log，評估是否刪除。</p>
<p><strong>新功能走設計產物流程</strong>：從下一個新功能開始，功能規格中包含可觀測性欄位。新功能的 log 從一開始就是設計產物品質。</p>
<p>過渡的第一步是建立統一入口，具體的 log 點規格格式見<a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a>。規格中的每個 log 點屬於哪一層（連線生命週期 / protocol / 使用者行為），在<a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a>中定義。收集到 log 之後用自架還是商業方案處理，見<a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a>的判斷流程。</p>
]]></content:encoded></item><item><title>設計瑕疵還是避免過度設計？YAGNI 的真實適用條件</title><link>https://tarrragon.github.io/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心命題&lt;/strong>：YAGNI 不是「永遠選最受限選項」的原則，是「不為未來投入額外成本」的原則。
&lt;strong>判斷工具&lt;/strong>：成本對稱性、可逆性、領域先驗——三軸框架。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="起點一個常見的工程爭論">起點：一個常見的工程爭論&lt;/h2>
&lt;p>「最早的設計者沒考慮到多個監聽需求，這算設計瑕疵，還是避免過度設計？」&lt;/p>
&lt;p>這類問題在 code review、事故檢討、技術選型討論裡反覆出現。指控太重會打擊個別工程師的判斷力信心，放任又會讓同類事故反覆發生。&lt;/p>
&lt;p>要釐清這個爭論，得先回到 YAGNI 原則的真實定義——很多被當成 YAGNI 的例子根本不在它的射程內。&lt;/p>
&lt;hr>
&lt;h2 id="yagni-的真實範圍">YAGNI 的真實範圍&lt;/h2>
&lt;p>YAGNI（You Aren&amp;rsquo;t Gonna Need It）的原意是：&lt;strong>不要投入額外成本去蓋你尚未需要的東西&lt;/strong>。它防的是這類情境：&lt;/p>
&lt;ul>
&lt;li>「我先寫個 plugin 系統，未來可以擴充」（成本：協議設計、抽象層、擴充點測試）&lt;/li>
&lt;li>「我先做多語系，未來會國際化」（成本：i18n 框架、所有字串外移）&lt;/li>
&lt;li>「我先支援多資料庫」（成本：repository 抽象、SQL 方言處理）&lt;/li>
&lt;li>「我先建多租戶切割」（成本：資料 schema 加 tenant 欄位、所有 query 加過濾）&lt;/li>
&lt;/ul>
&lt;p>這些選擇的共通特徵是：&lt;strong>為了未來付出當下的具體成本&lt;/strong>——抽象層、額外測試、複雜配置、學習負擔。YAGNI 說：別付，等真正需要再付，因為很可能你永遠不需要。&lt;/p>
&lt;p>但很多被指控為「過度設計」的選擇其實&lt;strong>沒有 upfront cost 差異&lt;/strong>。例如：&lt;/p>
&lt;ul>
&lt;li>Stream 工具用單訂閱版本還是廣播版本：建構子多打 11 個字元&lt;/li>
&lt;li>&lt;code>var&lt;/code> 還是 &lt;code>final&lt;/code>：3 個字元&lt;/li>
&lt;li>ID 用 &lt;code>int&lt;/code> 還是 &lt;code>String&lt;/code>（UUID）：抽象層成本一樣&lt;/li>
&lt;li>API 設計成同步還是 async：簽章只差 &lt;code>Future&amp;lt;&amp;gt;&lt;/code> 包裝&lt;/li>
&lt;li>Class 預設可繼承還是 sealed：一個 modifier&lt;/li>
&lt;li>Database column 預設 nullable 還是 NOT NULL：一個 keyword&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>這些不在 YAGNI 的射程內&lt;/strong>。把它們當成 YAGNI 來防禦會選錯方向。&lt;/p>
&lt;hr>
&lt;h2 id="真正的判斷軸成本不對稱性">真正的判斷軸：成本不對稱性&lt;/h2>
&lt;p>判斷「該不該選更通用的選項」，跑三個軸。&lt;/p>
&lt;h3 id="軸-1成本對稱性">軸 1：成本對稱性&lt;/h3>
&lt;p>「選擇 A 比選擇 B 多付出多少當下成本？」&lt;/p>
&lt;ul>
&lt;li>&lt;strong>對稱&lt;/strong>（成本相當、差幾個字元、無新概念）：選&lt;strong>未來更可能需要&lt;/strong>的那個——這不是過度設計，是合理 default&lt;/li>
&lt;li>&lt;strong>不對稱&lt;/strong>（一邊明顯較貴、要多寫框架、多加抽象、多學概念）：YAGNI 適用，選便宜的，需要時再升級&lt;/li>
&lt;/ul>
&lt;h3 id="軸-2改變決定的成本">軸 2：改變決定的成本&lt;/h3>
&lt;p>「如果選錯了，未來修正要付出什麼？」&lt;/p>
&lt;ul>
&lt;li>&lt;strong>可逆&lt;/strong>（一行改完、無 API 契約變動、無資料遷移）：YAGNI 適用，先選簡單的&lt;/li>
&lt;li>&lt;strong>不可逆 / 修正昂貴&lt;/strong>（牽動 API 契約、資料庫 schema、客戶端版本相容性、第三方 integration）：偏向預先選擇通用的&lt;/li>
&lt;/ul>
&lt;h3 id="軸-3領域先驗domain-prior">軸 3：領域先驗（domain prior）&lt;/h3>
&lt;p>「這個領域裡、這個模式發生的機率有多高？」——「先驗」（prior）借自 Bayesian 統計、用來指「在沒看到具體證據前、我們對某事發生機率的合理預期」。在工程領域、這個機率來自累積的領域知識（多視角同步、retry、併發、認證⋯⋯這些 pattern 的歷史發生率）。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>強先驗&lt;/strong>（教科書級別）：多視角狀態同步是廣播、有用戶系統一定有 logged-in / anonymous 兩種、長時間運行服務一定會有 retry 需求、有交易就會有併發&lt;/li>
&lt;li>&lt;strong>弱先驗&lt;/strong>（純臆測）：「未來可能會有 plugin 機制吧」「未來可能要換資料庫吧」「未來可能要支援其他平台吧」&lt;/li>
&lt;/ul>
&lt;h3 id="三軸的綜合判斷">三軸的綜合判斷&lt;/h3>
&lt;p>任一軸顯著偏向「該選通用」，YAGNI 就不適用。&lt;/p>
&lt;p>&lt;strong>選通用不是過度設計，是對工具屬性與領域常識的尊重&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="案例對照兩個極端">案例對照：兩個極端&lt;/h2>
&lt;h3 id="案例-astream-預設選錯">案例 A：Stream 預設選錯&lt;/h3>
&lt;p>某個事件廣播 service 用了 &lt;code>StreamController()&lt;/code> 預設建構子（單訂閱）。當下只有一個訂閱者，運作正常數個月。後來加第二個訂閱者，瞬間 throw &lt;code>Bad state: Stream has already been listened to&lt;/code>。&lt;/p>
&lt;p>跑三軸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>成本對稱性&lt;/strong>：對稱（差 11 個字元、零認知負擔）&lt;/li>
&lt;li>&lt;strong>可逆性&lt;/strong>：中等偏高（事故必須在 production 暴露才會發現，要審所有訂閱方、改實作 + mock）&lt;/li>
&lt;li>&lt;strong>領域先驗&lt;/strong>：強（pub-sub / 事件廣播場景天生多訂閱）&lt;/li>
&lt;/ul>
&lt;p>三軸都指向廣播版本。&lt;strong>這是設計瑕疵&lt;/strong>——不是因為「沒考慮多訂閱」，而是&lt;strong>在三軸都不利於單訂閱的情況下選了單訂閱&lt;/strong>。&lt;/p>
&lt;blockquote>
&lt;p>完整事故重現、單訂閱 vs broadcast 的程式碼對比、修復決策過程：&lt;a href="https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">Dart StreamController：single-subscription vs broadcast 的事故實錄&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;h3 id="案例-b建立-plugin-系統">案例 B：建立 plugin 系統&lt;/h3>
&lt;p>「我先建個 plugin 系統，未來功能模組可以動態擴充」——典型的 over-engineering 焦慮表現。&lt;/p>
&lt;p>跑三軸：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>成本對稱性&lt;/strong>：嚴重不對稱（plugin 系統需要設計協議、加載機制、版本管理、隔離測試）&lt;/li>
&lt;li>&lt;strong>可逆性&lt;/strong>：可逆（之後要做的話成本跟現在做差不多）&lt;/li>
&lt;li>&lt;strong>領域先驗&lt;/strong>：弱（多數應用程式不會有第三方擴充需求）&lt;/li>
&lt;/ul>
&lt;p>三軸都指向「先別做」。&lt;strong>這是 YAGNI 的標準適用情境&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心命題</strong>：YAGNI 不是「永遠選最受限選項」的原則，是「不為未來投入額外成本」的原則。
<strong>判斷工具</strong>：成本對稱性、可逆性、領域先驗——三軸框架。</p></blockquote>
<hr>
<h2 id="起點一個常見的工程爭論">起點：一個常見的工程爭論</h2>
<p>「最早的設計者沒考慮到多個監聽需求，這算設計瑕疵，還是避免過度設計？」</p>
<p>這類問題在 code review、事故檢討、技術選型討論裡反覆出現。指控太重會打擊個別工程師的判斷力信心，放任又會讓同類事故反覆發生。</p>
<p>要釐清這個爭論，得先回到 YAGNI 原則的真實定義——很多被當成 YAGNI 的例子根本不在它的射程內。</p>
<hr>
<h2 id="yagni-的真實範圍">YAGNI 的真實範圍</h2>
<p>YAGNI（You Aren&rsquo;t Gonna Need It）的原意是：<strong>不要投入額外成本去蓋你尚未需要的東西</strong>。它防的是這類情境：</p>
<ul>
<li>「我先寫個 plugin 系統，未來可以擴充」（成本：協議設計、抽象層、擴充點測試）</li>
<li>「我先做多語系，未來會國際化」（成本：i18n 框架、所有字串外移）</li>
<li>「我先支援多資料庫」（成本：repository 抽象、SQL 方言處理）</li>
<li>「我先建多租戶切割」（成本：資料 schema 加 tenant 欄位、所有 query 加過濾）</li>
</ul>
<p>這些選擇的共通特徵是：<strong>為了未來付出當下的具體成本</strong>——抽象層、額外測試、複雜配置、學習負擔。YAGNI 說：別付，等真正需要再付，因為很可能你永遠不需要。</p>
<p>但很多被指控為「過度設計」的選擇其實<strong>沒有 upfront cost 差異</strong>。例如：</p>
<ul>
<li>Stream 工具用單訂閱版本還是廣播版本：建構子多打 11 個字元</li>
<li><code>var</code> 還是 <code>final</code>：3 個字元</li>
<li>ID 用 <code>int</code> 還是 <code>String</code>（UUID）：抽象層成本一樣</li>
<li>API 設計成同步還是 async：簽章只差 <code>Future&lt;&gt;</code> 包裝</li>
<li>Class 預設可繼承還是 sealed：一個 modifier</li>
<li>Database column 預設 nullable 還是 NOT NULL：一個 keyword</li>
</ul>
<p><strong>這些不在 YAGNI 的射程內</strong>。把它們當成 YAGNI 來防禦會選錯方向。</p>
<hr>
<h2 id="真正的判斷軸成本不對稱性">真正的判斷軸：成本不對稱性</h2>
<p>判斷「該不該選更通用的選項」，跑三個軸。</p>
<h3 id="軸-1成本對稱性">軸 1：成本對稱性</h3>
<p>「選擇 A 比選擇 B 多付出多少當下成本？」</p>
<ul>
<li><strong>對稱</strong>（成本相當、差幾個字元、無新概念）：選<strong>未來更可能需要</strong>的那個——這不是過度設計，是合理 default</li>
<li><strong>不對稱</strong>（一邊明顯較貴、要多寫框架、多加抽象、多學概念）：YAGNI 適用，選便宜的，需要時再升級</li>
</ul>
<h3 id="軸-2改變決定的成本">軸 2：改變決定的成本</h3>
<p>「如果選錯了，未來修正要付出什麼？」</p>
<ul>
<li><strong>可逆</strong>（一行改完、無 API 契約變動、無資料遷移）：YAGNI 適用，先選簡單的</li>
<li><strong>不可逆 / 修正昂貴</strong>（牽動 API 契約、資料庫 schema、客戶端版本相容性、第三方 integration）：偏向預先選擇通用的</li>
</ul>
<h3 id="軸-3領域先驗domain-prior">軸 3：領域先驗（domain prior）</h3>
<p>「這個領域裡、這個模式發生的機率有多高？」——「先驗」（prior）借自 Bayesian 統計、用來指「在沒看到具體證據前、我們對某事發生機率的合理預期」。在工程領域、這個機率來自累積的領域知識（多視角同步、retry、併發、認證⋯⋯這些 pattern 的歷史發生率）。</p>
<ul>
<li><strong>強先驗</strong>（教科書級別）：多視角狀態同步是廣播、有用戶系統一定有 logged-in / anonymous 兩種、長時間運行服務一定會有 retry 需求、有交易就會有併發</li>
<li><strong>弱先驗</strong>（純臆測）：「未來可能會有 plugin 機制吧」「未來可能要換資料庫吧」「未來可能要支援其他平台吧」</li>
</ul>
<h3 id="三軸的綜合判斷">三軸的綜合判斷</h3>
<p>任一軸顯著偏向「該選通用」，YAGNI 就不適用。</p>
<p><strong>選通用不是過度設計，是對工具屬性與領域常識的尊重</strong>。</p>
<hr>
<h2 id="案例對照兩個極端">案例對照：兩個極端</h2>
<h3 id="案例-astream-預設選錯">案例 A：Stream 預設選錯</h3>
<p>某個事件廣播 service 用了 <code>StreamController()</code> 預設建構子（單訂閱）。當下只有一個訂閱者，運作正常數個月。後來加第二個訂閱者，瞬間 throw <code>Bad state: Stream has already been listened to</code>。</p>
<p>跑三軸：</p>
<ul>
<li><strong>成本對稱性</strong>：對稱（差 11 個字元、零認知負擔）</li>
<li><strong>可逆性</strong>：中等偏高（事故必須在 production 暴露才會發現，要審所有訂閱方、改實作 + mock）</li>
<li><strong>領域先驗</strong>：強（pub-sub / 事件廣播場景天生多訂閱）</li>
</ul>
<p>三軸都指向廣播版本。<strong>這是設計瑕疵</strong>——不是因為「沒考慮多訂閱」，而是<strong>在三軸都不利於單訂閱的情況下選了單訂閱</strong>。</p>
<blockquote>
<p>完整事故重現、單訂閱 vs broadcast 的程式碼對比、修復決策過程：<a href="/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">Dart StreamController：single-subscription vs broadcast 的事故實錄</a>。</p></blockquote>
<h3 id="案例-b建立-plugin-系統">案例 B：建立 plugin 系統</h3>
<p>「我先建個 plugin 系統，未來功能模組可以動態擴充」——典型的 over-engineering 焦慮表現。</p>
<p>跑三軸：</p>
<ul>
<li><strong>成本對稱性</strong>：嚴重不對稱（plugin 系統需要設計協議、加載機制、版本管理、隔離測試）</li>
<li><strong>可逆性</strong>：可逆（之後要做的話成本跟現在做差不多）</li>
<li><strong>領域先驗</strong>：弱（多數應用程式不會有第三方擴充需求）</li>
</ul>
<p>三軸都指向「先別做」。<strong>這是 YAGNI 的標準適用情境</strong>。</p>
<h3 id="兩個案例的對比">兩個案例的對比</h3>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>成本對稱性</th>
          <th>可逆性</th>
          <th>領域先驗</th>
          <th>該怎麼選</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stream 預設</td>
          <td>對稱</td>
          <td>中等偏高</td>
          <td>強</td>
          <td>提前選通用</td>
      </tr>
      <tr>
          <td>Plugin 系統</td>
          <td>嚴重不對稱</td>
          <td>可逆</td>
          <td>弱</td>
          <td>YAGNI（先別做）</td>
      </tr>
  </tbody>
</table>
<p>兩者表面看都是「未來可能需要」，但三軸框架告訴你它們是<strong>完全不同類別</strong>的決定。一概而論「該/不該為未來準備」會兩邊都做錯。</p>
<hr>
<h2 id="為什麼這類瑕疵可被原諒">為什麼這類瑕疵「可被原諒」</h2>
<p>要老實講：<strong>指出某個選擇是設計瑕疵，不等於把責任全部推給個別工程師</strong>。</p>
<p>同類型瑕疵在實務上極常見，原因往往是系統性陷阱。</p>
<h3 id="1-語言--工具的預設值誤導">1. 語言 / 工具的預設值誤導</h3>
<p>很多語言把「需要明確選擇」的東西做成「最少打字的預設」：</p>
<ul>
<li>Dart 的 <code>StreamController()</code> 是 single-subscription</li>
<li>多數 SQL 的 column 預設 nullable</li>
<li>JavaScript 的 <code>==</code> 預設寬鬆比對</li>
<li>多數語言的 class 預設可繼承</li>
<li>HTTP 預設不加密</li>
<li>多數語言的 mutable 是 default</li>
</ul>
<p>這些預設都把多數人推向「比較容易出錯但不立即爆」的選項。<strong>API 設計把成本均衡的選擇做成「便宜便輸出受限」vs「貴一點輸出通用」是 framework 設計的責任轉嫁</strong>——把跨用例的判斷成本丟給用戶。</p>
<h3 id="2-領域知識需要被觸發過才會內化">2. 領域知識需要被觸發過才會內化</h3>
<p>很多事是遇過一次才會記得。「stream 預設是單訂閱」「nullable column 之後加 NOT NULL 要 backfill」「同步 API 之後改 async 是 breaking change」——這些不是經驗少的問題，是這些事實<strong>需要遇到才會內化進直覺判斷</strong>。</p>
<p>新人讀文件不會看到、code review 不會自動 catch、靜態分析不會主動警告——只能等某次遇到。</p>
<h3 id="3-失敗模式的低調性掩蓋風險">3. 失敗模式的低調性掩蓋風險</h3>
<p>很多設計瑕疵的失敗模式只在特定觸發條件下顯現：</p>
<ul>
<li>Stream 多訂閱限制只在第二次 <code>listen()</code> 時暴露</li>
<li>Mutable shared state 的 race condition 只在高併發下爆</li>
<li>Cache 失效邏輯只在 cache miss 模式變化時出問題</li>
<li>API 沒做 idempotent 只在重試時出現重複</li>
</ul>
<p>平常測試跑都過，給人「沒問題」的錯覺。<strong>沒有立即反饋的設計瑕疵 = 隱形的技術債</strong>。</p>
<h3 id="4-工具替代品掩蓋知識需求">4. 工具替代品掩蓋知識需求</h3>
<p>有些底層概念被高層框架封裝後，使用者根本不會碰到，所以「應該知道」的知識沒有被反覆強化。例如：</p>
<ul>
<li>Flutter 開發者多用 GetX / Riverpod / Bloc，極少碰 raw <code>StreamController</code></li>
<li>ORM 用戶多不寫 SQL，極少思考 query plan</li>
<li>雲端 SDK 用戶多不思考 retry / backoff，極少接觸底層 HTTP</li>
</ul>
<p>當有一天必須繞過框架直接用底層工具時，那個事故就會發生。</p>
<h3 id="結論">結論</h3>
<p>設計者只承擔最後一棒。要把同類瑕疵變少，<strong>修補方向在制度層面</strong>。</p>
<hr>
<h2 id="制度層面的補強">制度層面的補強</h2>
<p>要把「該選通用 default 但選了受限預設」的錯誤變少，個人記憶不可靠，要靠三層機制。</p>
<h3 id="機制-1介面層的-review-checklist">機制 1：介面層的 review checklist</h3>
<p>把容易出錯的 default 列入 PR review 檢查清單。例如：</p>
<ul>
<li>Service 對外暴露 <code>Stream&lt;T&gt;</code> 時、預設用 broadcast；用 single 要在註解寫明理由</li>
<li>資料庫 column 預設用 NOT NULL；nullable 要在註解寫明業務理由</li>
<li>公開 API 預設用 async；sync 要寫明理由</li>
<li>公開類別預設用 sealed / final；可繼承要寫明理由</li>
<li>HTTP 預設用 HTTPS；plain HTTP 要寫明理由</li>
</ul>
<p>把「需要記得」變成「review 強制檢查」。Checklist 不需要多，每個項目對應一個遇過的事故。</p>
<h3 id="機制-2架構規範把選擇從-default-取消">機制 2：架構規範把選擇從 default 取消</h3>
<p>更徹底的做法是用工具或規範<strong>禁掉問題 default</strong>：</p>
<ul>
<li>App 層 service 禁用 raw <code>StreamController</code>，強制用框架的廣播原語</li>
<li>用 lint rule 警告 <code>StreamController()</code> 的無參數呼叫</li>
<li>DB schema migration 工具預設產出 NOT NULL，nullable 要明確指定</li>
<li>API gateway 預設 deny，要顯式 allow 才放行</li>
</ul>
<p>這把選擇從「需要記得」變成「<strong>不需要選，做錯會被擋</strong>」。是最高效的補強。</p>
<h3 id="機制-3領域先驗清單">機制 3：領域先驗清單</h3>
<p>每個團隊應該維護一份「<strong>我們的領域裡這些事一定會發生</strong>」的清單。範例：</p>
<p>POS 系統：</p>
<ul>
<li>一台主機要服務多視角（多顯示螢幕、多通知模組）</li>
<li>會員身份會即時切換</li>
<li>有離線運作需求</li>
<li>多分店不同設定</li>
</ul>
<p>電商：</p>
<ul>
<li>商品價格會變動，歷史訂單要保留下單當時的價格</li>
<li>庫存會超賣，需要 reserve / commit 機制</li>
<li>退款是必然發生的，不是 edge case</li>
<li>客戶會有多個收件地址</li>
</ul>
<p>新功能設計時對照清單——強領域先驗就直接設計進去，<strong>不必每次重新評估</strong>。新進團隊成員也能快速吸收領域常識。</p>
<hr>
<h2 id="一個能套到無數情境的-heuristic">一個能套到無數情境的 heuristic</h2>
<p>把整個討論濃縮成一句話：</p>
<blockquote>
<p>當你的選擇「<strong>沒有 upfront cost 差異</strong>」時、就該選未來自由度高的那個。</p></blockquote>
<p>這個 heuristic 能套到無數技術決定：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>「便宜但受限」</th>
          <th>「同樣便宜但通用」</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stream 廣播</td>
          <td><code>StreamController()</code></td>
          <td><code>StreamController.broadcast()</code></td>
      </tr>
      <tr>
          <td>集合不可變性</td>
          <td><code>var list = [1, 2]</code></td>
          <td><code>final list = const [1, 2]</code></td>
      </tr>
      <tr>
          <td>API 回傳值</td>
          <td>同步 method</td>
          <td><code>Future&lt;&gt;</code> 包裝</td>
      </tr>
      <tr>
          <td>函式參數</td>
          <td>positional args</td>
          <td>named args</td>
      </tr>
      <tr>
          <td>Class 設計</td>
          <td>預設可繼承</td>
          <td><code>sealed</code> / <code>final class</code></td>
      </tr>
      <tr>
          <td>Resource handle</td>
          <td>manual cleanup</td>
          <td>RAII / <code>using</code> block</td>
      </tr>
      <tr>
          <td>Time</td>
          <td>local time</td>
          <td>UTC + timezone metadata</td>
      </tr>
      <tr>
          <td>ID 型別</td>
          <td><code>int</code> auto-increment</td>
          <td><code>String</code> (UUID)</td>
      </tr>
      <tr>
          <td>Money</td>
          <td><code>double</code></td>
          <td>專用 <code>Decimal</code> 型別</td>
      </tr>
      <tr>
          <td>字串編碼</td>
          <td>平台預設</td>
          <td>顯式 UTF-8</td>
      </tr>
  </tbody>
</table>
<p>這些都不是「過度設計」，是<strong>在零成本差異下選擇未來自由度更高的選項</strong>。YAGNI 不適用——YAGNI 的成本門檻在這裡根本不存在。</p>
<hr>
<h2 id="反向校正什麼時候該堅持-yagni">反向校正：什麼時候該堅持 YAGNI？</h2>
<p>為了避免本文被讀成「永遠選通用」，補一個反向案例。</p>
<p>YAGNI 在這些情境是對的：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼 YAGNI 適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>「先做個 admin 後台，未來方便」</td>
          <td>成本巨大，需求未確認，可逆</td>
      </tr>
      <tr>
          <td>「先支援自訂主題系統」</td>
          <td>成本中等，弱領域先驗，可逆</td>
      </tr>
      <tr>
          <td>「先做 API rate limiting」</td>
          <td>成本中等，現階段流量沒問題，可逆</td>
      </tr>
      <tr>
          <td>「先設計 multi-region 部署」</td>
          <td>成本巨大，多數產品永遠單 region</td>
      </tr>
      <tr>
          <td>「先抽 service 層」</td>
          <td>成本中等，function 直接呼叫已經夠用</td>
      </tr>
  </tbody>
</table>
<p>這些都是<strong>為了未來付出當下具體成本</strong>——抽象層、新概念、額外測試、配置複雜度。YAGNI 在這些情境會帶你做出對的選擇。</p>
<p>判斷的差異是：<strong>這個決定是「選哪個免費選項」，還是「要不要付一筆額外開發成本」？</strong> 前者三軸框架；後者 YAGNI。</p>
<hr>
<h2 id="總結">總結</h2>
<p>YAGNI vs 過度設計的爭論，常常因為兩邊在用不同定義而無法收斂。釐清如下：</p>
<blockquote>
<p><strong>YAGNI 適用於「為了未來而付出當下的具體成本」</strong>
<strong>不適用於「在成本相當的選項中選擇更通用的那個」</strong></p></blockquote>
<p>判斷時跑三軸：</p>
<ol>
<li><strong>成本對稱性</strong>：兩個選項的 upfront cost 是否相當？</li>
<li><strong>可逆性</strong>：選錯的話修正昂貴嗎？</li>
<li><strong>領域先驗</strong>：這個模式在領域裡發生機率多高？</li>
</ol>
<p>任一軸顯著偏向「該選通用」，YAGNI 就不適用，這不是過度設計。</p>
<p>回到開頭問題——「最早的設計者沒考慮到多個監聽需求、這算設計瑕疵還是避免過度設計？」答案<strong>取決於這三軸的具體狀況</strong>、不能一概而論。</p>
<p>但如果像 Stream 這個案例、三軸全部不利於受限預設、那就是設計瑕疵。<strong>只是這類瑕疵反映的是工具預設與領域知識內化的系統性問題、不是個別工程師的判斷力不足</strong>——修補方向是制度而非個人責備。</p>
<h3 id="一句話帶走">一句話帶走</h3>
<p>日常情境中、把三軸壓縮成一個問題就夠用：</p>
<blockquote>
<p>「<strong>我在多付什麼成本？</strong>」</p></blockquote>
<ul>
<li>多付<strong>抽象層、新概念、額外測試</strong> → YAGNI 適用、先別付</li>
<li>多付<strong>幾個字元、一個關鍵字</strong> → 不是 YAGNI、選通用的</li>
</ul>
<p>需要更精細的時候、再回頭跑完整三軸框架。</p>
]]></content:encoded></item></channel></rss>