<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gregning.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gregning.github.io/" rel="alternate" type="text/html" /><updated>2026-08-11T06:37:46+00:00</updated><id>https://gregning.github.io/feed.xml</id><title type="html">Greg’s Blog</title><subtitle>Greg 的工程筆記：整理 Rails、Ruby、JavaScript、資料庫與部署實作，讓下一次遇到同樣問題時可以快速回查。</subtitle><author><name>Greg</name></author><entry><title type="html">Rails migration: change vs up/down 怎麼選</title><link href="https://gregning.github.io/rails/2026/04/24/rails-migration-change-vs-up-down/" rel="alternate" type="text/html" title="Rails migration: change vs up/down 怎麼選" /><published>2026-04-24T21:00:00+00:00</published><updated>2026-04-24T21:00:00+00:00</updated><id>https://gregning.github.io/rails/2026/04/24/rails-migration-change-vs-up-down</id><content type="html" xml:base="https://gregning.github.io/rails/2026/04/24/rails-migration-change-vs-up-down/"><![CDATA[<h3 id="兩種寫法">兩種寫法</h3>

<p>Rails migration 可以寫成：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">AddEmailToUsers</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>或明確分開：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">AddEmailToUsers</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">up</span>
    <span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">down</span>
    <span class="n">remove_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">change</code> 比較短，Rails 會<strong>自動推導出 rollback</strong> (<code class="language-plaintext highlighter-rouge">db:rollback</code> 時執行反向操作)。看起來 <code class="language-plaintext highlighter-rouge">change</code> 永遠好，但不是。</p>

<hr />

<h3 id="change-可以幫你推導的指令"><code class="language-plaintext highlighter-rouge">change</code> 可以幫你推導的指令</h3>

<p>官方清單 (<a href="https://api.rubyonrails.org/classes/ActiveRecord/Migration/CommandRecorder.html">API docs</a>)：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">create_table</code> ↔ <code class="language-plaintext highlighter-rouge">drop_table</code></li>
  <li><code class="language-plaintext highlighter-rouge">add_column</code> ↔ <code class="language-plaintext highlighter-rouge">remove_column</code></li>
  <li><code class="language-plaintext highlighter-rouge">add_index</code> ↔ <code class="language-plaintext highlighter-rouge">remove_index</code></li>
  <li><code class="language-plaintext highlighter-rouge">add_reference</code> ↔ <code class="language-plaintext highlighter-rouge">remove_reference</code></li>
  <li><code class="language-plaintext highlighter-rouge">add_foreign_key</code> ↔ <code class="language-plaintext highlighter-rouge">remove_foreign_key</code></li>
  <li><code class="language-plaintext highlighter-rouge">change_column_default</code> (帶 <code class="language-plaintext highlighter-rouge">from:</code> / <code class="language-plaintext highlighter-rouge">to:</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">change_column_null</code></li>
  <li><code class="language-plaintext highlighter-rouge">rename_column</code>, <code class="language-plaintext highlighter-rouge">rename_index</code>, <code class="language-plaintext highlighter-rouge">rename_table</code></li>
</ul>

<p>這些 Rails 知道怎麼反過來。<strong>只用這些</strong>的 migration 可以放心用 <code class="language-plaintext highlighter-rouge">change</code>。</p>

<hr />

<h3 id="什麼時候一定要寫-updown">什麼時候一定要寫 up/down</h3>

<p><strong>1. <code class="language-plaintext highlighter-rouge">change_column</code> 改型態</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">change</span>
  <span class="n">change_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:age</span><span class="p">,</span> <span class="ss">:integer</span>   <span class="c1"># ← 原本是什麼？</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Rails 不知道「原本是什麼型態」，rollback 時不知道要改回啥。必須：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">up</span>
  <span class="n">change_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:age</span><span class="p">,</span> <span class="ss">:integer</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">down</span>
  <span class="n">change_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:age</span><span class="p">,</span> <span class="ss">:string</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>2. 有 data migration (寫資料)</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">change</span>
  <span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:full_name</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="no">User</span><span class="p">.</span><span class="nf">reset_column_information</span>
  <span class="no">User</span><span class="p">.</span><span class="nf">find_each</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="n">u</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="ss">full_name: </span><span class="s2">"</span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">first_name</span><span class="si">}</span><span class="s2"> </span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">last_name</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>rollback 時怎麼還原？沒辦法。而且這整段 data migration 放 migration 裡本身就有問題 (下面再講)，但如果真的要做，至少要明確 <code class="language-plaintext highlighter-rouge">up</code> / <code class="language-plaintext highlighter-rouge">down</code>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">up</span>
  <span class="n">add_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:full_name</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="c1"># data migration</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">down</span>
  <span class="n">remove_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:full_name</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>3. <code class="language-plaintext highlighter-rouge">execute</code> 直接跑 SQL</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">change</span>
  <span class="n">execute</span> <span class="s2">"UPDATE users SET role = 'member' WHERE role IS NULL"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>跟上面同理，Rails 不會去 parse SQL 然後反推。必須分成 <code class="language-plaintext highlighter-rouge">up</code> / <code class="language-plaintext highlighter-rouge">down</code>，或用 <code class="language-plaintext highlighter-rouge">reversible</code>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">change</span>
  <span class="n">reversible</span> <span class="k">do</span> <span class="o">|</span><span class="n">dir</span><span class="o">|</span>
    <span class="n">dir</span><span class="p">.</span><span class="nf">up</span>   <span class="p">{</span> <span class="n">execute</span> <span class="s2">"UPDATE users SET role = 'member' WHERE role IS NULL"</span> <span class="p">}</span>
    <span class="n">dir</span><span class="p">.</span><span class="nf">down</span> <span class="p">{</span> <span class="n">execute</span> <span class="s2">"UPDATE users SET role = NULL WHERE role = 'member'"</span> <span class="p">}</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>4. 真的不可逆</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">up</span>
  <span class="n">remove_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:legacy_token</span>  <span class="c1"># 資料就這樣消失</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">down</span>
  <span class="k">raise</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">IrreversibleMigration</span>
<span class="k">end</span>
</code></pre></div></div>

<p>誠實寫出來，不要騙自己可以回。也可以在 <code class="language-plaintext highlighter-rouge">change</code> 裡用 Rails 提供的 block：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">change</span>
  <span class="n">remove_column</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:legacy_token</span><span class="p">,</span> <span class="ss">:string</span>   <span class="c1"># ← 注意：要指定原本的型態</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">remove_column</code> 帶型態時 Rails 可以從 <code class="language-plaintext highlighter-rouge">drop_column</code> 推回 <code class="language-plaintext highlighter-rouge">add_column</code>，反向可逆。</p>

<hr />

<h3 id="我自己的-rule-of-thumb">我自己的 rule of thumb</h3>

<ul>
  <li><strong>純 schema change、且在「Rails 知道怎麼 reverse 的清單」裡</strong> → 用 <code class="language-plaintext highlighter-rouge">change</code>。</li>
  <li><strong>含 data migration</strong> → 不應該跟 schema change 綁在同一個 migration，拆兩支。schema 那支用 <code class="language-plaintext highlighter-rouge">change</code>，data 那支用另一種機制 (下面講)。</li>
  <li><strong>含 <code class="language-plaintext highlighter-rouge">execute</code> SQL / <code class="language-plaintext highlighter-rouge">change_column</code> 改型</strong> → 寫 <code class="language-plaintext highlighter-rouge">up</code> / <code class="language-plaintext highlighter-rouge">down</code>，或用 <code class="language-plaintext highlighter-rouge">reversible</code>。</li>
  <li><strong>真的不可逆</strong> → 寫 <code class="language-plaintext highlighter-rouge">up</code>，在 <code class="language-plaintext highlighter-rouge">down</code> 明確 <code class="language-plaintext highlighter-rouge">raise IrreversibleMigration</code>。</li>
</ul>

<p><strong>不確定時優先寫 <code class="language-plaintext highlighter-rouge">up</code>/<code class="language-plaintext highlighter-rouge">down</code></strong>。多打幾行總比半夜 rollback 時 migration 自己說「我不知道怎麼回去」好。</p>

<hr />

<h3 id="data-migration-不要放-schema-migration-裡">Data migration 不要放 schema migration 裡</h3>

<p>即使你寫得漂漂亮亮的 <code class="language-plaintext highlighter-rouge">reversible</code>，實務上 <strong>schema migration 裡跑資料更新</strong> 還是有兩個麻煩：</p>

<p><strong>1. 大 table 會 timeout</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">User</span><span class="p">.</span><span class="nf">find_each</span> <span class="p">{</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span> <span class="n">u</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="ss">full_name: </span><span class="s2">"</span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">first_name</span><span class="si">}</span><span class="s2"> </span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">last_name</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="p">}</span>
</code></pre></div></div>

<p>1000 萬筆 user，這段跑幾小時。migration 鎖表、deploy 掛在那、DBA 電話進來。</p>

<p><strong>2. Model code 跟 schema 版本錯開</strong></p>

<p>migration 寫在 <code class="language-plaintext highlighter-rouge">20260424120000_backfill_full_name.rb</code> 時用 <code class="language-plaintext highlighter-rouge">User</code> model，半年後 <code class="language-plaintext highlighter-rouge">User</code> 已經加了新欄位、新 callback、新 validation。這支 migration 在 rollback 或 schema re-setup 時又被跑一次，可能 behavior 完全不一樣。</p>

<p>比較好的 pattern：<strong>schema migration 只改 schema，data backfill 跑 rake task 或獨立的 maintenance job</strong>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># lib/tasks/backfill_full_name.rake</span>
<span class="n">namespace</span> <span class="ss">:backfill</span> <span class="k">do</span>
  <span class="n">task</span> <span class="ss">full_name: :environment</span> <span class="k">do</span>
    <span class="no">User</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">full_name: </span><span class="kp">nil</span><span class="p">).</span><span class="nf">find_each</span><span class="p">(</span><span class="ss">batch_size: </span><span class="mi">1000</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">u</span><span class="o">|</span>
      <span class="n">u</span><span class="p">.</span><span class="nf">update_columns</span><span class="p">(</span><span class="ss">full_name: </span><span class="s2">"</span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">first_name</span><span class="si">}</span><span class="s2"> </span><span class="si">#{</span><span class="n">u</span><span class="p">.</span><span class="nf">last_name</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Deploy flow：</p>

<ol>
  <li>Deploy 帶 schema migration 的 commit → <code class="language-plaintext highlighter-rouge">add_column :users, :full_name</code>。</li>
  <li><code class="language-plaintext highlighter-rouge">rails backfill:full_name</code> 跑一次 (可中斷、可 monitor、可分批)。</li>
  <li>Deploy 移除舊欄位 / 加 NOT NULL constraint 的 commit。</li>
</ol>

<p>雖然步驟變多，但每一步都小、可回退、可 monitor。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">change</code> 給純 schema 用。</li>
  <li><code class="language-plaintext highlighter-rouge">change_column</code> 型態改變 / <code class="language-plaintext highlighter-rouge">execute</code> SQL / data migration → 寫 <code class="language-plaintext highlighter-rouge">up</code>/<code class="language-plaintext highlighter-rouge">down</code> 或用 <code class="language-plaintext highlighter-rouge">reversible</code>。</li>
  <li>真的不可逆就誠實 <code class="language-plaintext highlighter-rouge">raise IrreversibleMigration</code>。</li>
  <li>Schema migration 跟 data migration 分開。schema 用 <code class="language-plaintext highlighter-rouge">change</code>，data 用 rake task。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Rails" /><category term="Rails" /><category term="Migration" /><category term="ActiveRecord" /><summary type="html"><![CDATA[When change is safe, when you must write up/down explicitly]]></summary></entry><entry><title type="html">i18n locale JSON 載入失敗時不要強制 fallback 到 en</title><link href="https://gregning.github.io/frontend/2026/04/24/i18n-navigator-language-fallback/" rel="alternate" type="text/html" title="i18n locale JSON 載入失敗時不要強制 fallback 到 en" /><published>2026-04-24T20:30:00+00:00</published><updated>2026-04-24T20:30:00+00:00</updated><id>https://gregning.github.io/frontend/2026/04/24/i18n-navigator-language-fallback</id><content type="html" xml:base="https://gregning.github.io/frontend/2026/04/24/i18n-navigator-language-fallback/"><![CDATA[<h3 id="場景">場景</h3>

<p>前端多語系站通常是這樣做 lazy loading 的：</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">loadLocale</span><span class="p">(</span><span class="nx">locale</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`/locales/</span><span class="p">${</span><span class="nx">locale</span><span class="p">}</span><span class="s2">.json`</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">load failed</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="p">}</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">initI18n</span><span class="p">(</span><span class="nx">locale</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">zh-TW</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">messages</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">loadLocale</span><span class="p">(</span><span class="nx">locale</span><span class="p">);</span>
    <span class="nx">i18n</span><span class="p">.</span><span class="nx">setMessages</span><span class="p">(</span><span class="nx">messages</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// fallback</span>
    <span class="kd">const</span> <span class="nx">messages</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">loadLocale</span><span class="p">(</span><span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">i18n</span><span class="p">.</span><span class="nx">setMessages</span><span class="p">(</span><span class="nx">messages</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>看起來 reasonable — 抓不到就用英文。直到有一天客人反應：「我用 Chrome 開你們的網站，第一次進站明明 UI 語言是中文，但突然就跳英文了」。</p>

<h3 id="為什麼會這樣">為什麼會這樣</h3>

<p>幾個常見觸發點：</p>

<ol>
  <li><strong>第一次進站網路很爛</strong>：手機從 4G 轉 WiFi、地下室訊號差、CDN 抖一下。<code class="language-plaintext highlighter-rouge">fetch</code> timeout / 連線斷線 → <code class="language-plaintext highlighter-rouge">catch</code> 觸發 → 掉到 en。</li>
  <li><strong>CDN cache miss + 後端短暫 500</strong>：<code class="language-plaintext highlighter-rouge">/locales/zh-TW.json</code> 第一次真的回 500，之後就正常了。但那位 user 第一次的體驗就是 en。</li>
  <li><strong>service worker 還在 install</strong>：第一次 request 沒被 cache，service worker 繞路的過程中 race，fetch 失敗。</li>
</ol>

<p>後果都一樣：<strong>user 其實是中文使用者，但被強制看到英文</strong>。糟糕的 UX — 英文使用者反而不會受影響。</p>

<hr />

<h3 id="解法fallback-要看瀏覽器語言再決定">解法：fallback 要看瀏覽器語言再決定</h3>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">pickFallbackLocale</span><span class="p">(</span><span class="nx">supported</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">pref</span> <span class="o">=</span> <span class="nb">navigator</span><span class="p">.</span><span class="nx">languages</span> <span class="o">||</span> <span class="p">[</span><span class="nb">navigator</span><span class="p">.</span><span class="nx">language</span><span class="p">];</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">tag</span> <span class="k">of</span> <span class="nx">pref</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// 先試完整 match</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">supported</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">tag</span><span class="p">))</span> <span class="k">return</span> <span class="nx">tag</span><span class="p">;</span>
    <span class="c1">// 再試 primary subtag (zh-TW → zh)</span>
    <span class="kd">const</span> <span class="nx">primary</span> <span class="o">=</span> <span class="nx">tag</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">-</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">];</span>
    <span class="kd">const</span> <span class="nx">hit</span> <span class="o">=</span> <span class="nx">supported</span><span class="p">.</span><span class="nx">find</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">primary</span> <span class="o">+</span> <span class="dl">'</span><span class="s1">-</span><span class="dl">'</span><span class="p">)</span> <span class="o">||</span> <span class="nx">s</span> <span class="o">===</span> <span class="nx">primary</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">hit</span><span class="p">)</span> <span class="k">return</span> <span class="nx">hit</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">;</span> <span class="c1">// 真的都沒 match 才英文</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">SUPPORTED</span> <span class="o">=</span> <span class="p">[</span><span class="dl">'</span><span class="s1">zh-TW</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">zh-CN</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">ja</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">];</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">initI18n</span><span class="p">(</span><span class="nx">locale</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">locale</span> <span class="o">||</span> <span class="nx">pickFallbackLocale</span><span class="p">(</span><span class="nx">SUPPORTED</span><span class="p">);</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="nx">i18n</span><span class="p">.</span><span class="nx">setMessages</span><span class="p">(</span><span class="k">await</span> <span class="nx">loadLocale</span><span class="p">(</span><span class="nx">target</span><span class="p">));</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">fallback</span> <span class="o">=</span> <span class="nx">pickFallbackLocale</span><span class="p">(</span><span class="nx">SUPPORTED</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span> <span class="o">!==</span> <span class="nx">target</span><span class="p">));</span>
    <span class="nx">i18n</span><span class="p">.</span><span class="nx">setMessages</span><span class="p">(</span><span class="k">await</span> <span class="nx">loadLocale</span><span class="p">(</span><span class="nx">fallback</span><span class="p">));</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="要點">要點</h3>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">navigator.languages</code> 優先於 <code class="language-plaintext highlighter-rouge">navigator.language</code></strong>。前者是 array，按使用者設定的優先順序排。後者只有一個。</li>
  <li><strong>Primary subtag match</strong>。user browser 是 <code class="language-plaintext highlighter-rouge">zh-HK</code>，站只支援 <code class="language-plaintext highlighter-rouge">zh-TW</code> / <code class="language-plaintext highlighter-rouge">zh-CN</code> — 這時候 <code class="language-plaintext highlighter-rouge">zh-TW</code> (或 <code class="language-plaintext highlighter-rouge">zh-CN</code>，依你覺得誰離 HK 近) 比 en 合理太多。</li>
  <li><strong>fallback 的 fallback 也不要是 en</strong>。<code class="language-plaintext highlighter-rouge">[ja]</code> 掛掉時，user 是日文，退到 <code class="language-plaintext highlighter-rouge">zh-TW</code> 都比 en 合理一點 — 但這個 case 太邊界，多數情況下真的都掛掉就給 en 即可。</li>
  <li><strong>BCP 47 case</strong>：<code class="language-plaintext highlighter-rouge">zh-tw</code> / <code class="language-plaintext highlighter-rouge">zh-TW</code> / <code class="language-plaintext highlighter-rouge">ZH-TW</code> 要統一 normalize。瀏覽器通常回 <code class="language-plaintext highlighter-rouge">zh-TW</code>，但要防呆。</li>
</ol>

<hr />

<h3 id="如果你用-i18next--vue-i18n">如果你用 i18next / vue-i18n</h3>

<p>一樣的邏輯已經有現成選項：</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// i18next</span>
<span class="nx">i18next</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
  <span class="na">fallbackLng</span><span class="p">:</span> <span class="p">{</span>
    <span class="dl">'</span><span class="s1">zh-HK</span><span class="dl">'</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">zh-TW</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">zh-CN</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">],</span>
    <span class="dl">'</span><span class="s1">zh-MO</span><span class="dl">'</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">zh-TW</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">zh-CN</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">],</span>
    <span class="na">default</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">],</span>
  <span class="p">},</span>
  <span class="na">detection</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">order</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">querystring</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">cookie</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">navigator</span><span class="dl">'</span><span class="p">],</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p>重點不是哪個 lib，而是 <strong>「載入失敗」跟「語言沒支援」的 fallback 路徑要分開想</strong>。大部分 lib 預設只處理「沒支援」的 fallback，載入失敗那條路是你自己要補的。</p>

<hr />

<h3 id="還要記得user-override-要-persist">還要記得：user override 要 persist</h3>

<p>第一次靠 navigator 猜語言沒關係，但 user 手動切換語言之後，<strong>要記住</strong>。</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">getPreferredLocale</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">return</span> <span class="p">(</span>
    <span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">locale</span><span class="dl">'</span><span class="p">)</span> <span class="o">||</span>           <span class="c1">// 手動選過的</span>
    <span class="nx">pickFallbackLocale</span><span class="p">(</span><span class="nx">SUPPORTED</span><span class="p">)</span>               <span class="c1">// 沒選過就猜</span>
  <span class="p">);</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">setPreferredLocale</span><span class="p">(</span><span class="nx">locale</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">locale</span><span class="dl">'</span><span class="p">,</span> <span class="nx">locale</span><span class="p">);</span>
  <span class="nx">initI18n</span><span class="p">(</span><span class="nx">locale</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>不然就會變成「user 選了繁中、關掉視窗再進來，又變回簡中」— 一樣糟。</p>

<hr />

<h3 id="ssr-的版本">SSR 的版本</h3>

<p>SSR 的情境下沒有 <code class="language-plaintext highlighter-rouge">navigator</code>，要從 request header 看：</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Next.js / Remix / Express</span>
<span class="kd">function</span> <span class="nx">pickFromAcceptLanguage</span><span class="p">(</span><span class="nx">header</span><span class="p">,</span> <span class="nx">supported</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">header</span><span class="p">)</span> <span class="k">return</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">candidates</span> <span class="o">=</span> <span class="nx">header</span>
    <span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">)</span>
    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">part</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="p">[</span><span class="nx">tag</span><span class="p">,</span> <span class="nx">q</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">q=1</span><span class="dl">'</span><span class="p">]</span> <span class="o">=</span> <span class="nx">part</span><span class="p">.</span><span class="nx">trim</span><span class="p">().</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">;</span><span class="dl">'</span><span class="p">);</span>
      <span class="k">return</span> <span class="p">{</span> <span class="na">tag</span><span class="p">:</span> <span class="nx">tag</span><span class="p">.</span><span class="nx">trim</span><span class="p">(),</span> <span class="na">q</span><span class="p">:</span> <span class="nb">parseFloat</span><span class="p">(</span><span class="nx">q</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">=</span><span class="dl">'</span><span class="p">)[</span><span class="mi">1</span><span class="p">])</span> <span class="p">};</span>
    <span class="p">})</span>
    <span class="p">.</span><span class="nx">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">b</span><span class="p">.</span><span class="nx">q</span> <span class="o">-</span> <span class="nx">a</span><span class="p">.</span><span class="nx">q</span><span class="p">);</span>

  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="p">{</span> <span class="nx">tag</span> <span class="p">}</span> <span class="k">of</span> <span class="nx">candidates</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">supported</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">tag</span><span class="p">))</span> <span class="k">return</span> <span class="nx">tag</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">primary</span> <span class="o">=</span> <span class="nx">tag</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">-</span><span class="dl">'</span><span class="p">)[</span><span class="mi">0</span><span class="p">];</span>
    <span class="kd">const</span> <span class="nx">hit</span> <span class="o">=</span> <span class="nx">supported</span><span class="p">.</span><span class="nx">find</span><span class="p">(</span><span class="nx">s</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="nx">primary</span><span class="p">));</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">hit</span><span class="p">)</span> <span class="k">return</span> <span class="nx">hit</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="dl">'</span><span class="s1">en</span><span class="dl">'</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>邏輯是一樣的，只是資料來源從 <code class="language-plaintext highlighter-rouge">navigator.languages</code> 換成 <code class="language-plaintext highlighter-rouge">Accept-Language</code> header。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>載入 locale JSON 失敗 → 直接 fallback <code class="language-plaintext highlighter-rouge">en</code> 是<strong>常見 UX bug</strong>。</li>
  <li>用 <code class="language-plaintext highlighter-rouge">navigator.languages</code> + primary subtag match 決定 fallback，英文只是最後的保底。</li>
  <li>user 手動切換過的語言要 persist (localStorage / cookie)。</li>
  <li>SSR 情境讀 <code class="language-plaintext highlighter-rouge">Accept-Language</code> header，邏輯相同。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Frontend" /><category term="i18n" /><category term="JavaScript" /><category term="Frontend" /><category term="UX" /><summary type="html"><![CDATA[Respect navigator.language when the initial locale JSON request fails]]></summary></entry><entry><title type="html">Docker container 用 non-root + log 目錄權限問題</title><link href="https://gregning.github.io/devops/2026/04/24/docker-non-root-log-permission/" rel="alternate" type="text/html" title="Docker container 用 non-root + log 目錄權限問題" /><published>2026-04-24T20:00:00+00:00</published><updated>2026-04-24T20:00:00+00:00</updated><id>https://gregning.github.io/devops/2026/04/24/docker-non-root-log-permission</id><content type="html" xml:base="https://gregning.github.io/devops/2026/04/24/docker-non-root-log-permission/"><![CDATA[<h3 id="為什麼-container-不該用-root-跑">為什麼 container 不該用 root 跑</h3>

<p>預設 <code class="language-plaintext highlighter-rouge">docker run</code> 跑出來的 process UID 是 0 (root)。這帶來幾個問題：</p>

<ol>
  <li><strong>Container escape 風險</strong>：雖然有 namespace 隔離，但萬一 runtime (containerd / runc) 有洞，root in container 比 non-root 造成的傷害大得多。</li>
  <li><strong>掛載宿主目錄時檔案權限變 root:root</strong>：宿主系統使用者之後要去清就要 sudo。</li>
  <li><strong>某些 managed 平台 (OpenShift、GKE Autopilot、某些 PaaS) 根本不給 root 跑</strong>，container 啟動直接失敗。</li>
  <li><strong>合規</strong>：SOC 2、ISO 27001、PCI-DSS audit 常見要求就是「container 不可以用 root」。</li>
</ol>

<p>所以正確 pattern 是：</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> python:3.12-slim</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> requirements.txt .</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
<span class="k">COPY</span><span class="s"> . .</span>

<span class="c"># 建立 non-root user</span>
<span class="k">RUN </span>useradd <span class="nt">--create-home</span> <span class="nt">--shell</span> /bin/bash appuser
<span class="k">USER</span><span class="s"> appuser</span>

<span class="k">CMD</span><span class="s"> ["uvicorn", "app:app", "--host", "0.0.0.0"]</span>
</code></pre></div></div>

<p>但會踩到下一個坑。</p>

<hr />

<h3 id="典型錯誤log-目錄寫不進去">典型錯誤：log 目錄寫不進去</h3>

<p>啟動 container：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PermissionError: [Errno 13] Permission denied: '/app/logs/app.log'
</code></pre></div></div>

<p>或用 Python logging RotatingFileHandler 的話：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FileNotFoundError: [Errno 2] No such file or directory: '/app/logs'
</code></pre></div></div>

<p>原因是：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">COPY . .</code> 時那些檔案的 owner 是 <strong>root</strong> (build stage 跑到 <code class="language-plaintext highlighter-rouge">USER appuser</code> 之前所有東西都還是 root-owned)。</li>
  <li>Application 要寫的 <code class="language-plaintext highlighter-rouge">/app/logs</code> 不存在、或存在但是 root owner、appuser 無權寫入。</li>
</ol>

<hr />

<h3 id="正確-dockerfile-寫法">正確 Dockerfile 寫法</h3>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> python:3.12-slim</span>

<span class="c"># 先建 user (固定 UID 方便 volume 權限管理)</span>
<span class="k">RUN </span>groupadd <span class="nt">-g</span> 1000 appuser <span class="o">&amp;&amp;</span> <span class="se">\
</span>    useradd <span class="nt">-u</span> 1000 <span class="nt">-g</span> 1000 <span class="nt">-m</span> appuser

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># 把 WORKDIR 跟之後會 COPY 進來的東西 ownership 交給 appuser</span>
<span class="k">COPY</span><span class="s"> --chown=appuser:appuser requirements.txt .</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">--no-cache-dir</span> <span class="nt">-r</span> requirements.txt

<span class="k">COPY</span><span class="s"> --chown=appuser:appuser . .</span>

<span class="c"># 建 runtime 會寫入的目錄，先開好權限</span>
<span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /app/logs <span class="o">&amp;&amp;</span> <span class="nb">chown</span> <span class="nt">-R</span> appuser:appuser /app/logs

<span class="k">USER</span><span class="s"> appuser</span>

<span class="k">CMD</span><span class="s"> ["uvicorn", "app:app", "--host", "0.0.0.0"]</span>
</code></pre></div></div>

<h3 id="幾個要點">幾個要點</h3>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">--chown=</code> 在 COPY 指令上</strong>。比 <code class="language-plaintext highlighter-rouge">COPY . . &amp;&amp; RUN chown -R appuser /app</code> 好很多：第二種寫法會多一層 image，而且某些檔案大時 <code class="language-plaintext highlighter-rouge">chown</code> 本身就幾十 MB 的 layer。</li>
  <li><strong>runtime 會寫入的目錄 build time 就先建好</strong>。log、cache、tmp、upload buffer…… 全部提前 <code class="language-plaintext highlighter-rouge">mkdir -p &amp;&amp; chown</code>。</li>
  <li><strong>固定 UID/GID</strong>。如果會用 <code class="language-plaintext highlighter-rouge">volumes:</code> 掛宿主目錄，固定 UID 方便你在宿主上管權限：<code class="language-plaintext highlighter-rouge">chown -R 1000:1000 /host/logs</code>。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">WORKDIR</code> 的 owner 一般不用特別處理</strong>，但只要 app 要在 WORKDIR 直接寫檔 (log、sqlite dev db、webpack manifest 之類)，也要 <code class="language-plaintext highlighter-rouge">chown</code>。</li>
</ol>

<hr />

<h3 id="把-log-改導到-stdout-才是更好的答案">把 log 改導到 stdout 才是更好的答案</h3>

<p>上面的解法是讓 appuser 可以寫檔。但 containerized service 的最佳實踐是：<strong>log 全部寫到 stdout / stderr</strong>，讓 container runtime 負責收集。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Python
</span><span class="kn">import</span> <span class="nn">logging</span><span class="p">,</span> <span class="n">sys</span>
<span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span>
    <span class="n">stream</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stdout</span><span class="p">,</span>
    <span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">INFO</span><span class="p">,</span>
    <span class="nb">format</span><span class="o">=</span><span class="s">'%(asctime)s %(levelname)s %(name)s %(message)s'</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Rails config/environments/production.rb</span>
<span class="n">config</span><span class="p">.</span><span class="nf">logger</span> <span class="o">=</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Logger</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="no">STDOUT</span><span class="p">)</span>
<span class="n">config</span><span class="p">.</span><span class="nf">log_formatter</span> <span class="o">=</span> <span class="o">::</span><span class="no">Logger</span><span class="o">::</span><span class="no">Formatter</span><span class="p">.</span><span class="nf">new</span>
</code></pre></div></div>

<p>這樣：</p>

<ul>
  <li>不用管目錄權限。</li>
  <li>k8s / ECS / Cloud Run 內建 log aggregation 直接收得到。</li>
  <li>Container 重啟時 log 不會丟 (跟容器內的 file log 不一樣)。</li>
  <li>12-Factor app 第 11 條講的就是這個。</li>
</ul>

<p><strong>只有要做 local debug、或應用本身要產生 audit file</strong> (ex: ECPay 付款 log 需要留檔對帳) 才會真的寫到 file。那時候前面那段 Dockerfile 的 chown 處理就派上用場。</p>

<hr />

<h3 id="常見變形alpine--distroless">常見變形：alpine / distroless</h3>

<p>Alpine base image (<code class="language-plaintext highlighter-rouge">python:3.12-alpine</code>、<code class="language-plaintext highlighter-rouge">node:20-alpine</code>) 的 <code class="language-plaintext highlighter-rouge">useradd</code> 不存在，要用 <code class="language-plaintext highlighter-rouge">adduser</code>：</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>addgroup <span class="nt">-g</span> 1000 appuser <span class="o">&amp;&amp;</span> <span class="se">\
</span>    adduser <span class="nt">-u</span> 1000 <span class="nt">-G</span> appuser <span class="nt">-D</span> appuser
</code></pre></div></div>

<p>Distroless base image (<code class="language-plaintext highlighter-rouge">gcr.io/distroless/python3</code>) 裡根本沒有 shell，也沒辦法 <code class="language-plaintext highlighter-rouge">useradd</code>。Distroless 已經內建 <code class="language-plaintext highlighter-rouge">nonroot</code> user (UID 65532)：</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> gcr.io/distroless/python3</span>
<span class="k">COPY</span><span class="s"> --from=builder --chown=65532:65532 /app /app</span>
<span class="k">USER</span><span class="s"> 65532</span>
<span class="k">CMD</span><span class="s"> ["app.py"]</span>
</code></pre></div></div>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>Container 預設 root，要主動切到 non-root user。</li>
  <li>Build time 就要把 runtime 會讀寫的檔案/目錄都 <code class="language-plaintext highlighter-rouge">--chown</code> 給那個 user。</li>
  <li>Log 寫 stdout，不要寫 file — 大部分情況下連權限問題都不會有。</li>
  <li>Alpine / distroless 的 user 建法不一樣，抄前先確認 base image。</li>
</ul>]]></content><author><name>Greg</name></author><category term="DevOps" /><category term="Docker" /><category term="Security" /><category term="Permissions" /><summary type="html"><![CDATA[Run as appuser, but logs fail with permission denied. How to fix.]]></summary></entry><entry><title type="html">別在 Docker CMD 裡跑 migration</title><link href="https://gregning.github.io/devops/2026/04/24/dont-run-migrations-in-docker-cmd/" rel="alternate" type="text/html" title="別在 Docker CMD 裡跑 migration" /><published>2026-04-24T19:30:00+00:00</published><updated>2026-04-24T19:30:00+00:00</updated><id>https://gregning.github.io/devops/2026/04/24/dont-run-migrations-in-docker-cmd</id><content type="html" xml:base="https://gregning.github.io/devops/2026/04/24/dont-run-migrations-in-docker-cmd/"><![CDATA[<h3 id="反-pattern">反 pattern</h3>

<p>很多 Dockerfile / docker-compose / k8s manifest 一開始會這樣寫：</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Python / FastAPI</span>
<span class="k">CMD</span><span class="s"> ["sh", "-c", "alembic upgrade head &amp;&amp; uvicorn app:app --host 0.0.0.0"]</span>
</code></pre></div></div>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Rails</span>
<span class="k">CMD</span><span class="s"> ["sh", "-c", "bundle exec rails db:migrate &amp;&amp; bundle exec rails server"]</span>
</code></pre></div></div>

<p>每個 container 啟動時先跑 migration 再起 service。單 replica 時看起來很合理、很方便，但只要 replica &gt; 1 或有 rolling deploy，就會踩雷。</p>

<hr />

<h3 id="踩雷點-1多-replica-同時-migrate">踩雷點 1：多 replica 同時 migrate</h3>

<p><code class="language-plaintext highlighter-rouge">replicas: 3</code> 的時候，k8s / ECS / Cloud Run 會<strong>同時</strong>啟三個 container。三個都跑：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alembic upgrade head
</code></pre></div></div>

<p>誰先拿到 migration 鎖 (alembic / Rails 都是用 <code class="language-plaintext highlighter-rouge">CREATE TABLE alembic_version</code> / <code class="language-plaintext highlighter-rouge">schema_migrations</code>) 誰就 run，其他兩個的狀況：</p>

<ul>
  <li><strong>Alembic</strong>：預設沒鎖 — 三個同時跑 <code class="language-plaintext highlighter-rouge">ALTER TABLE</code> 極大概率撞死 (MySQL metadata lock 互卡，或 DDL 直接衝突)。</li>
  <li><strong>Rails</strong>：有 advisory lock，但等鎖時間可能超過 container health check timeout — 被 k8s 當作啟動失敗殺掉重啟，下一輪再重新搶 — <strong>死循環 crashloop</strong>。</li>
</ul>

<p>即使勉強跑過，log 會長得像：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alembic.util.exc.CommandError: Can't locate revision identified by 'abc123'
</code></pre></div></div>

<p>因為 replica A 把 revision 推到 abc123，replica B/C 的 container 啟動當下讀到的 metadata 已經不是它以為的狀態，alembic 就糊掉。</p>

<hr />

<h3 id="踩雷點-2rollback-時-schema-比-code-新">踩雷點 2：rollback 時 schema 比 code 新</h3>

<p>Deploy v2 (帶 migration)：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t=0  replica A:  migrate → schema v2 ✅ + 跑 v2 code ✅
t=1  replica B:  啟動 → alembic upgrade head (已經 head 了 noop) → 跑 v2 code ✅
t=2  發現 v2 有 bug，rollback 回 v1
t=3  replica A/B/C 全部回到 v1 code，但 DB schema 還在 v2
</code></pre></div></div>

<p>v1 code 對 v2 schema 能不能跑，不保證。新增欄位通常安全、DROP COLUMN 幾乎必死、NOT NULL 新欄位會炸。Migration 跟 service start 綁在一起，代表<strong>沒有一個明確的時間點可以 decouple schema 版本跟 code 版本</strong>，rollback 選擇就變得超難。</p>

<hr />

<h3 id="踩雷點-3health-check-起不來">踩雷點 3：Health check 起不來</h3>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CMD</span><span class="s"> ["sh", "-c", "alembic upgrade head &amp;&amp; uvicorn ..."]</span>
</code></pre></div></div>

<p>大 migration 跑 10 分鐘。k8s readiness probe 預設 10 秒沒 ready 就重啟。結果是 container 重複啟動、重複<strong>嘗試</strong>跑 migration、從頭再來。Migration 永遠跑不完、service 永遠起不來。</p>

<hr />

<h3 id="正解migration-當成-one-off-job">正解：migration 當成 one-off job</h3>

<p><strong>Pattern A：CI/CD 裡單獨跑一步</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/deploy.yml</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run DB migrations</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">docker run --rm \</span>
      <span class="s">-e DATABASE_URL=$DATABASE_URL \</span>
      <span class="s">$IMAGE \</span>
      <span class="s">alembic upgrade head</span>

<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy service</span>
  <span class="na">run</span><span class="pi">:</span> <span class="s">kubectl set image deployment/api api=$IMAGE</span>
</code></pre></div></div>

<p>Migration 跑完再 deploy。一次、單點、log 清楚。</p>

<p><strong>Pattern B：Kubernetes Job</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">batch/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Job</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">migrate-${VERSION}</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">backoffLimit</span><span class="pi">:</span> <span class="m">0</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">restartPolicy</span><span class="pi">:</span> <span class="s">Never</span>
      <span class="na">containers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">migrate</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:${VERSION}</span>
        <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">alembic"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">upgrade"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">head"</span><span class="pi">]</span>
</code></pre></div></div>

<p>Deploy pipeline 先 <code class="language-plaintext highlighter-rouge">kubectl apply</code> 這個 Job，等它結束再 deploy Deployment。</p>

<p><strong>Pattern C：init container (小心用)</strong></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spec</span><span class="pi">:</span>
  <span class="na">initContainers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">migrate</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:${VERSION}</span>
    <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">alembic"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">upgrade"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">head"</span><span class="pi">]</span>
  <span class="na">containers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">api</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:${VERSION}</span>
</code></pre></div></div>

<p>每個 pod 啟動時跑一次 migration。解決了 health check 的問題 (init container 不計 readiness)，但<strong>沒解決多 replica 並發 migrate</strong> 的問題。除非你的 migration 都是 idempotent、且有外部鎖機制，否則 Pattern A/B 還是比較安全。</p>

<hr />

<h3 id="dockerfile-應該長怎樣">Dockerfile 應該長怎樣</h3>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> python:3.12-slim</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> requirements.txt .</span>
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
<span class="k">COPY</span><span class="s"> . .</span>

<span class="c"># CMD 只負責起 service</span>
<span class="k">CMD</span><span class="s"> ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]</span>
</code></pre></div></div>

<p><strong>不要在 CMD 裡做任何只該發生一次的事</strong>：migration、seed、cache warm-up、certificate fetch…… 這些都是 admin process / one-off job，跟 service process 切開。</p>

<hr />

<h3 id="例外真的只有一個-replica">例外：真的只有一個 replica</h3>

<p>Hobby project、內部工具、只有一個 container 的小服務，<code class="language-plaintext highlighter-rouge">CMD: migrate &amp;&amp; server</code> 是可以接受的簡化 — 如果你很確定<strong>以後也不會 scale 到 &gt;1</strong>。一旦未來要 scale，就會踩前面三個雷，而重構時機通常挑得不好 (往往是 prod 正在燒的時候)。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>Migration 跟 service start <strong>不要放在同一個 CMD</strong>。</li>
  <li>多 replica 同時 migrate 會死在 lock / health check / schema drift。</li>
  <li>正解：CI/CD step、k8s Job，migration 是 one-off admin process。</li>
  <li>Twelve-Factor app 第六條 (Processes) + 第十二條 (Admin processes) 講的就是這個。</li>
</ul>]]></content><author><name>Greg</name></author><category term="DevOps" /><category term="Docker" /><category term="Alembic" /><category term="Rails" /><category term="Migration" /><category term="Deployment" /><summary type="html"><![CDATA[Why alembic upgrade head / rails db:migrate in CMD deadlocks multi-replica deploys]]></summary></entry><entry><title type="html">CarrierWave 在 CI 上亂打 GCS 的坑</title><link href="https://gregning.github.io/rails/2026/04/24/carrierwave-storage-test-override/" rel="alternate" type="text/html" title="CarrierWave 在 CI 上亂打 GCS 的坑" /><published>2026-04-24T19:00:00+00:00</published><updated>2026-04-24T19:00:00+00:00</updated><id>https://gregning.github.io/rails/2026/04/24/carrierwave-storage-test-override</id><content type="html" xml:base="https://gregning.github.io/rails/2026/04/24/carrierwave-storage-test-override/"><![CDATA[<h3 id="症狀">症狀</h3>

<p>CI 上突然整片紅，每個 spec 只要 <code class="language-plaintext highlighter-rouge">create(:product)</code> (或任何會建 uploader 的 factory) 就噴：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Google::Auth::CredentialsError: keyfile not a valid file
# 或
Google::Auth::CredentialsError: credentials type '' is not supported
</code></pre></div></div>

<p>local 不會，因為 local 有完整的 GCS credentials file。CI 沒有，就爆。</p>

<h3 id="表象先不談根本原因是uploader-在跑-spec-的時候試著連-gcs">表象先不談，根本原因是：uploader 在<strong>跑 spec 的時候</strong>試著連 GCS</h3>

<p>聽起來很廢話，但按照一般 CarrierWave 的 test setup 應該不會這樣：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># spec/support/carrierwave.rb</span>
<span class="no">CarrierWave</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">storage</span> <span class="o">=</span> <span class="ss">:file</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">enable_processing</span> <span class="o">=</span> <span class="kp">false</span>
<span class="k">end</span>
</code></pre></div></div>

<p>理論上這個 global 設定會讓所有 uploader 在 test 環境用 local file。為什麼沒生效？</p>

<hr />

<h3 id="原因class-level-storage-declaration-會-override-global-config">原因：class-level storage declaration 會 override global config</h3>

<p>看一下你的 app 是不是有這種 uploader：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">UploaderBase</span> <span class="o">&lt;</span> <span class="no">CarrierWave</span><span class="o">::</span><span class="no">Uploader</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">storage</span> <span class="ss">:gcloud</span>        <span class="c1"># ← 這一行</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">FaviconUploader</span> <span class="o">&lt;</span> <span class="no">UploaderBase</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">SmsKycDocumentUploader</span> <span class="o">&lt;</span> <span class="no">UploaderBase</span>
  <span class="n">storage</span> <span class="ss">:gcloud</span>        <span class="c1"># ← 或這一行</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">storage :gcloud</code> 在 class 定義時就把 <code class="language-plaintext highlighter-rouge">_storage</code> 變數寫死到該 class 上。CarrierWave 的 lookup 順序是：</p>

<ol>
  <li>instance 的 <code class="language-plaintext highlighter-rouge">_storage</code></li>
  <li><strong>class 的 <code class="language-plaintext highlighter-rouge">_storage</code></strong>  ← 卡在這</li>
  <li>parent class 的 <code class="language-plaintext highlighter-rouge">_storage</code></li>
  <li>global <code class="language-plaintext highlighter-rouge">CarrierWave.configure</code></li>
</ol>

<p>也就是說，只要 uploader class 裡有 <code class="language-plaintext highlighter-rouge">storage :gcloud</code>，<strong>global config 永遠搶不過 class-level</strong>。<code class="language-plaintext highlighter-rouge">CarrierWave.configure</code> 在 spec/support 裡設的 <code class="language-plaintext highlighter-rouge">:file</code> 對這些 class 完全無效。</p>

<h3 id="為什麼-local-看起來沒事">為什麼 local 看起來沒事</h3>

<p>local 環境 gcloud credentials file 是齊全的，所以即使真的走了 GCS 初始化路徑，也不會拋錯 — 只是在背景靜默連上 production bucket，你不會在 spec log 裡看到。</p>

<p>（是的，這代表你 local 跑 spec 的時候，可能在對 <strong>production GCS 打 request</strong>。這本身就該修。）</p>

<hr />

<h3 id="修法spec-land-only不要動-app-code">修法：spec land only，不要動 app code</h3>

<p>不要改 uploader。改 uploader 容易出錯 (忘了 merge、env 判斷寫在 uploader 很醜)，也會影響 dev 環境的行為。</p>

<p>在 <code class="language-plaintext highlighter-rouge">spec/rails_helper.rb</code> 或 <code class="language-plaintext highlighter-rouge">spec/support/carrierwave.rb</code>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">RSpec</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">before</span><span class="p">(</span><span class="ss">:suite</span><span class="p">)</span> <span class="k">do</span>
    <span class="c1"># eager load，讓所有 uploader subclass 都被 Ruby 認得</span>
    <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">eager_load!</span>

    <span class="c1"># 強制所有 CarrierWave uploader 在 spec 走 :file</span>
    <span class="no">CarrierWave</span><span class="o">::</span><span class="no">Uploader</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">descendants</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">klass</span><span class="o">|</span>
      <span class="n">klass</span><span class="p">.</span><span class="nf">storage</span> <span class="ss">:file</span>
    <span class="k">end</span>

    <span class="c1"># 關掉 image processing，避免 spec 真的跑 MiniMagick</span>
    <span class="no">CarrierWave</span><span class="o">::</span><span class="no">Uploader</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">descendants</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">klass</span><span class="o">|</span>
      <span class="n">klass</span><span class="p">.</span><span class="nf">enable_processing</span> <span class="o">=</span> <span class="kp">false</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="這段做了什麼">這段做了什麼</h3>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">eager_load!</code></strong>：development / test 預設是 autoload，不先 <code class="language-plaintext highlighter-rouge">eager_load!</code> 的話 <code class="language-plaintext highlighter-rouge">descendants</code> 只會看到已經 require 過的 uploader。</li>
  <li><strong>走到每個 subclass 把 <code class="language-plaintext highlighter-rouge">_storage</code> 覆寫成 <code class="language-plaintext highlighter-rouge">:file</code></strong>：這次是 class-level 寫入，就會蓋掉 uploader 檔案裡寫的 <code class="language-plaintext highlighter-rouge">storage :gcloud</code>。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">enable_processing = false</code></strong>：關 image processing，spec 不會真的跑 thumbnail，快很多。</li>
</ol>

<hr />

<h3 id="為什麼不用-carrierwaveconfigure-就夠">為什麼不用 <code class="language-plaintext highlighter-rouge">CarrierWave.configure</code> 就夠？</h3>

<p>前面講過，class-level 的 <code class="language-plaintext highlighter-rouge">storage :gcloud</code> 搶在 global config 前面。global configure 只有對 <strong>沒宣告 storage 的 uploader</strong> 生效。</p>

<h3 id="為什麼不用-envrails_env-條件在-uploader-裡判斷">為什麼不用 <code class="language-plaintext highlighter-rouge">ENV['RAILS_ENV']</code> 條件在 uploader 裡判斷？</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">UploaderBase</span> <span class="o">&lt;</span> <span class="no">CarrierWave</span><span class="o">::</span><span class="no">Uploader</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">storage</span><span class="p">(</span><span class="no">Rails</span><span class="p">.</span><span class="nf">env</span><span class="p">.</span><span class="nf">test?</span> <span class="p">?</span> <span class="ss">:file</span> <span class="p">:</span> <span class="ss">:gcloud</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>能動，但：</p>

<ul>
  <li>uploader 開始背業務無關的環境判斷。</li>
  <li>CI 上要切換測試環境 (ex: integration test 真的要測 GCS) 時沒彈性。</li>
  <li><code class="language-plaintext highlighter-rouge">storage</code> 呼叫時機是 class 定義期，某些 preloading 組合下會比 Rails.env 早。</li>
</ul>

<p>比起這個，把測試策略放在 spec/support 是更乾淨的 separation of concerns。</p>

<hr />

<h3 id="順便active-storage-也有類似問題">順便：Active Storage 也有類似問題</h3>

<p>Active Storage 用的是 <code class="language-plaintext highlighter-rouge">config/storage.yml</code> + <code class="language-plaintext highlighter-rouge">config.active_storage.service</code>，一般情況下：</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/storage.yml</span>
<span class="na">test</span><span class="pi">:</span>
  <span class="na">service</span><span class="pi">:</span> <span class="s">Disk</span>
  <span class="na">root</span><span class="pi">:</span> <span class="s">&lt;%= Rails.root.join("tmp/storage") %&gt;</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/environments/test.rb</span>
<span class="n">config</span><span class="p">.</span><span class="nf">active_storage</span><span class="p">.</span><span class="nf">service</span> <span class="o">=</span> <span class="ss">:test</span>
</code></pre></div></div>

<p>這兩個沒齊全的話，test 也會跑到 production 的 service 名字。新開 project 時 check 一下。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>CarrierWave class-level <code class="language-plaintext highlighter-rouge">storage :gcloud</code> 會 override 全域 test config。</li>
  <li>修在 spec side：<code class="language-plaintext highlighter-rouge">before(:suite)</code> eager load + <code class="language-plaintext highlighter-rouge">descendants.each { |k| k.storage :file }</code>。</li>
  <li>不要動 app code 裡的 uploader。</li>
  <li>CI 上「寫到 production bucket」這種事，local 可能看不出來；固定檢查 spec log 是不是有對外請求很重要。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Rails" /><category term="Rails" /><category term="CarrierWave" /><category term="Testing" /><category term="CI" /><summary type="html"><![CDATA[Class-level storage :gcloud overrides global test config. Force :file in specs.]]></summary></entry><entry><title type="html">用 ActiveSupport::MessageEncryptor 加密 per-tenant API key</title><link href="https://gregning.github.io/rails/2026/04/24/rails-message-encryptor-per-tenant-secrets/" rel="alternate" type="text/html" title="用 ActiveSupport::MessageEncryptor 加密 per-tenant API key" /><published>2026-04-24T18:30:00+00:00</published><updated>2026-04-24T18:30:00+00:00</updated><id>https://gregning.github.io/rails/2026/04/24/rails-message-encryptor-per-tenant-secrets</id><content type="html" xml:base="https://gregning.github.io/rails/2026/04/24/rails-message-encryptor-per-tenant-secrets/"><![CDATA[<h3 id="問題">問題</h3>

<p>SaaS 專案有一種很常見的需求：<strong>每個 tenant (shop / team / org) 自己填 API key</strong>。OpenAI / LINE Pay / ECPay 等等，各家第三方 API 的金鑰，不能寫死在 <code class="language-plaintext highlighter-rouge">config/secrets.yml</code> 或 <code class="language-plaintext highlighter-rouge">ENV</code>，因為每個 tenant 都不一樣。</p>

<p>最糟的實作是直接把金鑰<strong>明文存 DB</strong>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:shop_ai_credentials</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">bigint</span> <span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:access_token</span>   <span class="c1"># ← 明文</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>這樣 DB backup 一外洩、DBA 一下 query、log 不小心吐出來，全部 tenant 的金鑰就全跟著走。</p>

<hr />

<h3 id="rails-71-內建的-encrypts">Rails 7.1+ 內建的 encrypts</h3>

<p>Rails 7.1 之後直接支援 attribute-level encryption：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ShopAiCredential</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">encrypts</span> <span class="ss">:access_token</span>
<span class="k">end</span>
</code></pre></div></div>

<p>底層會幫你處理加密 / 解密 / key rotation，相當方便。如果你的 Rails 夠新，<strong>直接用這個就好</strong>。</p>

<p>但如果你在 Rails 6/7.0、或因為別的原因不能用 <code class="language-plaintext highlighter-rouge">encrypts</code>，可以手刻一個基於 <code class="language-plaintext highlighter-rouge">ActiveSupport::MessageEncryptor</code> 的版本。</p>

<hr />

<h3 id="手刻版messageencryptor">手刻版：MessageEncryptor</h3>

<p><strong>Migration</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:shop_ai_credentials</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">bigint</span> <span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:access_token_encrypted</span>     <span class="c1"># 加密過的存這欄</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:model</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Model</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ShopAiCredential</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:shop</span>

  <span class="k">def</span> <span class="nf">access_token</span>
    <span class="k">return</span> <span class="kp">nil</span> <span class="k">if</span> <span class="n">access_token_encrypted</span><span class="p">.</span><span class="nf">blank?</span>
    <span class="n">encryptor</span><span class="p">.</span><span class="nf">decrypt_and_verify</span><span class="p">(</span><span class="n">access_token_encrypted</span><span class="p">)</span>
  <span class="k">rescue</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">MessageEncryptor</span><span class="o">::</span><span class="no">InvalidMessage</span>
    <span class="kp">nil</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">access_token</span><span class="o">=</span><span class="p">(</span><span class="n">plaintext</span><span class="p">)</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">access_token_encrypted</span> <span class="o">=</span>
      <span class="n">plaintext</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="n">encryptor</span><span class="p">.</span><span class="nf">encrypt_and_sign</span><span class="p">(</span><span class="n">plaintext</span><span class="p">)</span> <span class="p">:</span> <span class="kp">nil</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">encryptor</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">encryptor</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">encryptor</span>
    <span class="vi">@encryptor</span> <span class="o">||=</span> <span class="k">begin</span>
      <span class="n">key</span> <span class="o">=</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">KeyGenerator</span>
              <span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">secret_key_base</span><span class="p">)</span>
              <span class="p">.</span><span class="nf">generate_key</span><span class="p">(</span><span class="s2">"shop_ai_credential.access_token.v1"</span><span class="p">,</span> <span class="mi">32</span><span class="p">)</span>
      <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">MessageEncryptor</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="幾個要點">幾個要點</h3>

<ol>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">secret_key_base</code> 當 master key</strong>。Rails 自己已經要求這是高熵隨機值，拿來 derive 沒問題。Production 走 credentials、env var、secret manager 都可以，重點是不要進 git。</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">KeyGenerator</code> + salt</strong>。不要直接把 <code class="language-plaintext highlighter-rouge">secret_key_base</code> 塞給 MessageEncryptor。KeyGenerator 是 PBKDF2，會針對這個欄位的用途 derive 出獨立的 key。salt 帶版本號 (<code class="language-plaintext highlighter-rouge">"v1"</code>)，之後要 rotate 時可以換 <code class="language-plaintext highlighter-rouge">"v2"</code> 做 migration。</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">encrypt_and_sign</code> / <code class="language-plaintext highlighter-rouge">decrypt_and_verify</code></strong>。用帶驗證的方法 (內部其實是 AES-GCM)，密文被竄改時 <code class="language-plaintext highlighter-rouge">decrypt_and_verify</code> 會拋 <code class="language-plaintext highlighter-rouge">InvalidMessage</code>，而不是默默吐出亂碼。</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">@encryptor</code> memoize 在 class 層級</strong>。每次讀寫都重做 PBKDF2 會慢 (PBKDF2 就是設計來慢的)，memoize 到 process 記憶體就好。</p>
  </li>
  <li>
    <p><strong>rescue <code class="language-plaintext highlighter-rouge">InvalidMessage</code> 回 nil</strong>。某些情境下 (ex: secret_key_base 真的換了、或 DB 裡有歷史髒資料) 你會希望解密失敗時 app 不要整個爆掉。要不要 rescue 看需求 — 走 fail-fast 也合理。</p>
  </li>
</ol>

<hr />

<h3 id="表單端注意事項">表單端注意事項</h3>

<p>Admin UI 常見需求是「編輯這張 credential 的模型設定，但不想強制重填金鑰」：</p>

<div class="language-slim highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">=</span> <span class="n">form_with</span> <span class="ss">model: </span><span class="n">credential</span> <span class="k">do</span> <span class="o">|</span><span class="n">f</span><span class="o">|</span>
  <span class="p">=</span> <span class="n">f</span><span class="p">.</span><span class="nf">password_field</span> <span class="ss">:access_token</span><span class="p">,</span>
      <span class="ss">placeholder: </span><span class="n">credential</span><span class="p">.</span><span class="nf">access_token_encrypted</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="s2">"已設定 — 留空則保留現有金鑰"</span> <span class="p">:</span> <span class="s2">"輸入金鑰"</span>
</code></pre></div></div>

<p>Controller 端：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">credential_params</span>
  <span class="n">permitted</span> <span class="o">=</span> <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:shop_ai_credential</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:provider</span><span class="p">,</span> <span class="ss">:model</span><span class="p">,</span> <span class="ss">:access_token</span><span class="p">)</span>
  <span class="n">permitted</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:access_token</span><span class="p">)</span> <span class="k">if</span> <span class="n">permitted</span><span class="p">[</span><span class="ss">:access_token</span><span class="p">].</span><span class="nf">blank?</span>
  <span class="n">permitted</span>
<span class="k">end</span>
</code></pre></div></div>

<p>空字串就從 params 拿掉，<code class="language-plaintext highlighter-rouge">access_token=</code> setter 就不會被呼叫，<code class="language-plaintext highlighter-rouge">access_token_encrypted</code> 保留原值。</p>

<hr />

<h3 id="為什麼不用-symmetric-encryption-gem-ex-lockboxattr_encrypted">為什麼不用 symmetric encryption gem (ex: lockbox、attr_encrypted)</h3>

<ul>
  <li>少一個 dependency，<code class="language-plaintext highlighter-rouge">ActiveSupport::MessageEncryptor</code> 已經在 Rails 裡，維護成本低。</li>
  <li>需求簡單 (單一欄位、單一 purpose) 時，gem 的功能都用不到。</li>
  <li>gem 通常會綁自己的 key schema，之後要遷移到 Rails 7.1 <code class="language-plaintext highlighter-rouge">encrypts</code> 反而多一步。</li>
</ul>

<p>如果你需要 <strong>blind index</strong> (加密後還能 query)、或 <strong>key rotation 自動化</strong>，gem 的功能才開始有價值。</p>

<hr />

<h3 id="不會保護你的東西">不會保護你的東西</h3>

<ul>
  <li><strong>Memory dump</strong>：程式跑的時候金鑰解密後在記憶體裡，root 存取 process 記憶體就看得到。</li>
  <li><strong>Log 不小心印出來</strong>：<code class="language-plaintext highlighter-rouge">Rails.logger.info credential.access_token</code> 就前功盡棄，code review 要盯。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">secret_key_base</code> 本身外洩</strong>：key 跟 ciphertext 一起外洩等於沒加密。最低限度：production 不把它放在 git、ideally 放進 secret manager (GCP Secret Manager、AWS KMS、Vault)。</li>
</ul>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>Rails 7.1+：直接 <code class="language-plaintext highlighter-rouge">encrypts :access_token</code>。</li>
  <li>7.0 以下：手刻一個 <code class="language-plaintext highlighter-rouge">MessageEncryptor</code> + <code class="language-plaintext highlighter-rouge">KeyGenerator</code> 包裝，30 行解決。</li>
  <li>加密欄位命名帶 <code class="language-plaintext highlighter-rouge">_encrypted</code> 後綴，欄位型態用 <code class="language-plaintext highlighter-rouge">text</code>，不要用 <code class="language-plaintext highlighter-rouge">string</code> (密文比明文長)。</li>
  <li>log / inspect / as_json 要確認不會吐出明文或密文。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Rails" /><category term="Rails" /><category term="Security" /><category term="Encryption" /><summary type="html"><![CDATA[Encrypt tenant secrets at rest without extra gems]]></summary></entry><entry><title type="html">MySQL NULL distinct 與 paranoid 的 unique index 陷阱</title><link href="https://gregning.github.io/mysql/2026/04/24/mysql-null-distinct-paranoid-unique/" rel="alternate" type="text/html" title="MySQL NULL distinct 與 paranoid 的 unique index 陷阱" /><published>2026-04-24T18:00:00+00:00</published><updated>2026-04-24T18:00:00+00:00</updated><id>https://gregning.github.io/mysql/2026/04/24/mysql-null-distinct-paranoid-unique</id><content type="html" xml:base="https://gregning.github.io/mysql/2026/04/24/mysql-null-distinct-paranoid-unique/"><![CDATA[<h3 id="問題">問題</h3>

<p>使用 paranoia (或 <code class="language-plaintext highlighter-rouge">acts_as_paranoid</code>、或自己手刻 <code class="language-plaintext highlighter-rouge">deleted_at</code> 欄位) 做 soft delete 的 Rails 專案，常會看到這種 migration：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:shop_ai_credentials</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">bigint</span> <span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">datetime</span> <span class="ss">:deleted_at</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>

<span class="n">add_index</span> <span class="ss">:shop_ai_credentials</span><span class="p">,</span> <span class="p">[</span><span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">:deleted_at</span><span class="p">],</span> <span class="ss">unique: </span><span class="kp">true</span>
</code></pre></div></div>

<p>邏輯上很合理：「同一個 shop 下同一個 provider 只能有一筆<strong>還活著</strong>的記錄。已刪除的 (deleted_at 有值) 不算。」</p>

<p>實際跑起來會發現：<strong>同一個 (shop_id, provider) 可以塞進多筆 <code class="language-plaintext highlighter-rouge">deleted_at IS NULL</code> 的 row</strong>。unique index 沒擋到。</p>

<h3 id="原因mysql-把每一個-null-當成不同的值">原因：MySQL 把每一個 NULL 當成不同的值</h3>

<p>SQL 標準規定 <code class="language-plaintext highlighter-rouge">NULL != NULL</code> (三值邏輯的 UNKNOWN)。MySQL 的 unique index 實作也是這樣：<strong>對 unique 檢查來說，每個 NULL 都是獨一無二的</strong>。</p>

<p>所以 <code class="language-plaintext highlighter-rouge">[1, 'openai', NULL]</code> 跟 <code class="language-plaintext highlighter-rouge">[1, 'openai', NULL]</code> 在 MySQL 看起來是<strong>兩個不同的 tuple</strong>，unique 檢查直接放行。</p>

<p>這個行為不是 bug，是規格。PostgreSQL 15 以前也一樣，PG 15 之後才加了 <code class="language-plaintext highlighter-rouge">UNIQUE NULLS NOT DISTINCT</code> 選項。MySQL 8.x 目前還沒有對應語法。</p>

<hr />

<h3 id="正確做法">正確做法</h3>

<p><strong>拿掉 deleted_at，unique index 只管「活著的」場景</strong>：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add_index</span> <span class="ss">:shop_ai_credentials</span><span class="p">,</span> <span class="p">[</span><span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">:provider</span><span class="p">],</span> <span class="ss">unique: </span><span class="kp">true</span>
</code></pre></div></div>

<p>然後刪除時不要真的 soft delete 相同 row，而是處理掉衝突。</p>

<p>但這樣會跟 paranoia 的 default scope 打架 — soft-deleted 的 row 還在表裡，第二次再插 <code class="language-plaintext highlighter-rouge">[shop_id, provider]</code> 會撞 unique。</p>

<p>有幾種常見解：</p>

<p><strong>方法 A：刪除時同時改寫 provider</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">soft_delete!</span>
  <span class="n">update!</span><span class="p">(</span>
    <span class="ss">deleted_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">,</span>
    <span class="ss">provider: </span><span class="s2">"</span><span class="si">#{</span><span class="n">provider</span><span class="si">}</span><span class="s2">__deleted_</span><span class="si">#{</span><span class="no">SecureRandom</span><span class="p">.</span><span class="nf">hex</span><span class="p">(</span><span class="mi">4</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span>
  <span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>讓歷史記錄保留，但 <code class="language-plaintext highlighter-rouge">provider</code> 欄位被「破壞」成不會撞到活著記錄的值。缺點：provider 欄位不再純粹，查詢歷史時要處理。</p>

<p><strong>方法 B：硬刪除 (真的 DELETE)，另外留一張 audit 表</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">destroy_with_audit!</span>
  <span class="no">ShopAiCredentialAudit</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="n">attributes</span><span class="p">)</span>
  <span class="n">destroy!</span>    <span class="c1"># 真 DELETE</span>
<span class="k">end</span>
</code></pre></div></div>

<p>乾淨，但要多維護一張表。適合「歷史記錄不常查但要留」的情境。</p>

<p><strong>方法 C：用 generated column</strong></p>

<p>MySQL 5.7+ 支援 generated column，可以把 NULL 轉成穩定值：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">shop_ai_credentials</span>
  <span class="k">ADD</span> <span class="k">COLUMN</span> <span class="n">deleted_at_nn</span> <span class="nb">DATETIME</span>
  <span class="k">AS</span> <span class="p">(</span><span class="n">IFNULL</span><span class="p">(</span><span class="n">deleted_at</span><span class="p">,</span> <span class="s1">'1970-01-01 00:00:00'</span><span class="p">))</span> <span class="n">STORED</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">UNIQUE</span> <span class="k">INDEX</span> <span class="n">ix_shop_provider_deleted_nn</span>
  <span class="k">ON</span> <span class="n">shop_ai_credentials</span> <span class="p">(</span><span class="n">shop_id</span><span class="p">,</span> <span class="n">provider</span><span class="p">,</span> <span class="n">deleted_at_nn</span><span class="p">);</span>
</code></pre></div></div>

<p>活著的時候所有 row 都是 <code class="language-plaintext highlighter-rouge">1970-01-01</code>，撞到就擋；刪掉的時候 <code class="language-plaintext highlighter-rouge">deleted_at_nn</code> 變真實時間，各自獨立。</p>

<p>這方法保留了原始 schema 的語意，但 migration / schema.rb 會變醜，其他開發者不一定看得懂為什麼有個 <code class="language-plaintext highlighter-rouge">_nn</code> 欄位。</p>

<hr />

<h3 id="為什麼不選在-application-層檢查">為什麼不選「在 application 層檢查」</h3>

<p>跟前一篇 race condition 的結論一樣：<strong>application 層擋不住並發</strong>。兩個 request 同時過驗證、同時 INSERT，DB 沒有 unique constraint 的話，就兩筆都進去了。</p>

<p>paranoia + 並發寫 + 沒有正確的 unique index，production 一定會出現「同一個 shop 有兩筆 active 的同 provider credential」的髒資料，而且很難追。</p>

<hr />

<h3 id="檢查你現在的-schema">檢查你現在的 schema</h3>

<p>掃一下所有用 paranoia 的表，找出可疑的 unique index：</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
  <span class="k">TABLE_NAME</span><span class="p">,</span>
  <span class="n">INDEX_NAME</span><span class="p">,</span>
  <span class="n">GROUP_CONCAT</span><span class="p">(</span><span class="k">COLUMN_NAME</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">SEQ_IN_INDEX</span><span class="p">)</span> <span class="k">AS</span> <span class="n">cols</span>
<span class="k">FROM</span> <span class="n">information_schema</span><span class="p">.</span><span class="k">STATISTICS</span>
<span class="k">WHERE</span> <span class="n">TABLE_SCHEMA</span> <span class="o">=</span> <span class="k">DATABASE</span><span class="p">()</span>
  <span class="k">AND</span> <span class="n">NON_UNIQUE</span> <span class="o">=</span> <span class="mi">0</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="k">TABLE_NAME</span><span class="p">,</span> <span class="n">INDEX_NAME</span>
<span class="k">HAVING</span> <span class="n">cols</span> <span class="k">LIKE</span> <span class="s1">'%deleted_at%'</span><span class="p">;</span>
</code></pre></div></div>

<p>每一列都值得檢查一次：這個 unique 真的有在擋嗎？</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li>MySQL / 老版本 PG 的 unique index 對 NULL 是「每個 NULL 都不同」。</li>
  <li><code class="language-plaintext highlighter-rouge">[..., deleted_at]</code> unique + paranoia 幾乎一定是 bug。</li>
  <li>解法：要嘛刪除時改寫某欄位，要嘛用 generated column，要嘛硬刪除 + audit。</li>
  <li>凡是用 soft delete 的表，schema review 一定要專門檢查 unique index。</li>
</ul>]]></content><author><name>Greg</name></author><category term="MySQL" /><category term="MySQL" /><category term="Rails" /><category term="Paranoia" /><category term="Soft-Delete" /><summary type="html"><![CDATA[Why [shop_id, provider, deleted_at] unique does not block duplicate active rows]]></summary></entry><entry><title type="html">Rails uniqueness 的兩個經典坑：Race Condition 與 Replica Lag</title><link href="https://gregning.github.io/rails/2026/04/24/rails-uniqueness-race-replica-lag/" rel="alternate" type="text/html" title="Rails uniqueness 的兩個經典坑：Race Condition 與 Replica Lag" /><published>2026-04-24T17:30:00+00:00</published><updated>2026-04-24T17:30:00+00:00</updated><id>https://gregning.github.io/rails/2026/04/24/rails-uniqueness-race-replica-lag</id><content type="html" xml:base="https://gregning.github.io/rails/2026/04/24/rails-uniqueness-race-replica-lag/"><![CDATA[<h3 id="情境">情境</h3>

<p>有一張 <code class="language-plaintext highlighter-rouge">shop_product_photos</code> 表，需求是：「同一個 shop 下，圖片檔名要唯一」。很直覺地會寫：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ShopProductPhoto</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:shop</span>
  <span class="n">validates</span> <span class="ss">:filename</span><span class="p">,</span> <span class="ss">uniqueness: </span><span class="p">{</span> <span class="ss">scope: :shop_id</span> <span class="p">}</span>

  <span class="n">before_validation</span> <span class="ss">:ensure_unique_filename</span>

  <span class="k">def</span> <span class="nf">ensure_unique_filename</span>
    <span class="k">return</span> <span class="k">unless</span> <span class="n">filename_changed?</span>
    <span class="n">base</span> <span class="o">=</span> <span class="n">filename</span>
    <span class="n">i</span> <span class="o">=</span> <span class="mi">2</span>
    <span class="k">while</span> <span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">shop_id: </span><span class="n">shop_id</span><span class="p">,</span> <span class="ss">filename: </span><span class="n">filename</span><span class="p">).</span><span class="nf">exists?</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">filename</span> <span class="o">=</span> <span class="s2">"</span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">_</span><span class="si">#{</span><span class="n">i</span><span class="si">}</span><span class="s2">"</span>
      <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>然後在 production 就會不定時 500：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ActiveRecord::RecordNotUnique: Mysql2::Error:
Duplicate entry 'photo_1-shop_42' for key 'index_shop_product_photos_on_filename_and_shop_id'
</code></pre></div></div>

<h3 id="坑-1toctou--time-of-check-to-time-of-use">坑 1：TOCTOU — Time Of Check To Time Of Use</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t=0  Request A: SELECT ... WHERE filename='photo_1' → 不存在
t=1  Request B: SELECT ... WHERE filename='photo_1' → 不存在
t=2  Request A: INSERT 'photo_1'  ← 成功
t=3  Request B: INSERT 'photo_1'  ← 撞 unique index，500
</code></pre></div></div>

<p>兩個 request 在 <code class="language-plaintext highlighter-rouge">exists?</code> 那一瞬間都看不到對方。Rails 的 <code class="language-plaintext highlighter-rouge">validates_uniqueness_of</code> 跟上面的 callback 都是這個 pattern，都擋不住這種 race。</p>

<h3 id="坑-2primary--replica-lag">坑 2：Primary / Replica Lag</h3>

<p>Production 通常會把讀寫分流：寫走 primary，讀走 replica。<code class="language-plaintext highlighter-rouge">ApplicationRecord</code> 如果設定 <code class="language-plaintext highlighter-rouge">connected_to role: :reading</code>，<code class="language-plaintext highlighter-rouge">exists?</code> 可能跑到 replica。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t=0  Primary: INSERT 'photo_1' by Worker A     (commit)
t=1  Replica: (還沒 replicate 過來)
t=2  Worker B: exists? → 打 replica → false    (看不到 A 寫入)
t=3  Worker B: INSERT 'photo_1' → 撞 primary unique index，500
</code></pre></div></div>

<p>這不是 race condition，是 eventual consistency。幾十 ms 的 lag 就足夠中獎，而且比 TOCTOU 更難 reproduce。</p>

<hr />

<h3 id="真正的唯一性來源db-unique-index">真正的唯一性來源：DB unique index</h3>

<p>Callback / validation 都只是 <strong>best effort</strong>，用來 UX 友善 (拒絕一些常見的重複、提早 fail) 。<strong>唯一性唯一可靠的保證是 DB 的 unique index。</strong> 寫 migration 時一定要加：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add_index</span> <span class="ss">:shop_product_photos</span><span class="p">,</span> <span class="p">[</span><span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">:filename</span><span class="p">],</span> <span class="ss">unique: </span><span class="kp">true</span>
</code></pre></div></div>

<p>然後應用層要 <strong>預期 unique index 會撞到，並且處理它</strong>。</p>

<hr />

<h3 id="重試--改名的模式">重試 + 改名的模式</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ShopProductPhoto</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="no">UNIQUE_FILENAME_INDEX</span> <span class="o">=</span> <span class="s1">'index_shop_product_photos_on_filename_and_shop_id'</span>
  <span class="no">MAX_RETRIES</span> <span class="o">=</span> <span class="mi">10</span>

  <span class="k">def</span> <span class="nf">save_with_unique_filename!</span>
    <span class="n">base</span> <span class="o">=</span> <span class="n">filename</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sr">/_\d+\z/</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span>   <span class="c1"># 把舊的 _N 剝掉重算</span>
    <span class="n">taken</span> <span class="o">=</span> <span class="no">Set</span><span class="p">.</span><span class="nf">new</span>
    <span class="n">attempt</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="k">begin</span>
      <span class="n">save!</span>
    <span class="k">rescue</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span> <span class="o">=&gt;</span> <span class="n">e</span>
      <span class="k">raise</span> <span class="k">unless</span> <span class="n">e</span><span class="p">.</span><span class="nf">message</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="no">UNIQUE_FILENAME_INDEX</span><span class="p">)</span>
      <span class="k">raise</span> <span class="k">if</span> <span class="p">(</span><span class="n">attempt</span> <span class="o">+=</span> <span class="mi">1</span><span class="p">)</span> <span class="o">&gt;</span> <span class="no">MAX_RETRIES</span>

      <span class="n">taken</span> <span class="o">&lt;&lt;</span> <span class="n">filename</span>
      <span class="c1"># 從 primary 重查一次，拿最新已存在的檔名</span>
      <span class="n">existing</span> <span class="o">=</span> <span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">unscoped</span>
                     <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">shop_id: </span><span class="n">shop_id</span><span class="p">)</span>
                     <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="s1">'filename LIKE ?'</span><span class="p">,</span> <span class="s2">"</span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">%"</span><span class="p">)</span>
                     <span class="p">.</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:filename</span><span class="p">)</span>
      <span class="n">taken</span><span class="p">.</span><span class="nf">merge</span><span class="p">(</span><span class="n">existing</span><span class="p">)</span>

      <span class="n">i</span> <span class="o">=</span> <span class="mi">2</span>
      <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span> <span class="k">while</span> <span class="n">taken</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"</span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">_</span><span class="si">#{</span><span class="n">i</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">filename</span> <span class="o">=</span> <span class="s2">"</span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">_</span><span class="si">#{</span><span class="n">i</span><span class="si">}</span><span class="s2">"</span>

      <span class="k">retry</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="要點說明">要點說明</h3>

<ol>
  <li><strong>只 rescue 特定 index</strong>。<code class="language-plaintext highlighter-rouge">RecordNotUnique</code> 也會因為其他 unique index (ex: <code class="language-plaintext highlighter-rouge">email</code>) 觸發；亂 rescue 會把別的錯誤吞掉，所以要比對 message。</li>
  <li><strong>把撞失敗的檔名也塞進 taken</strong>。replica lag 會讓 LIKE 查詢「看不到自己剛撞到的那個」，再跑一次就又挑到同一個名字。手動塞進去避免死循環。</li>
  <li><strong>MAX_RETRIES</strong>。沒有上限就是 DoS。10 次通常足夠，超過就是有別的問題 (真的有一萬個同名檔案？或者 bug)。</li>
  <li><strong>一定要 primary 重查</strong>。Rails 6+ 可以 <code class="language-plaintext highlighter-rouge">ApplicationRecord.connected_to(role: :writing) do ... end</code> 強制走 primary。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">_N</code> 要剝掉重算</strong>。不然重試時會一路往上 (<code class="language-plaintext highlighter-rouge">_2</code> → <code class="language-plaintext highlighter-rouge">_2_2</code> → <code class="language-plaintext highlighter-rouge">_2_2_2</code>)。</li>
</ol>

<hr />

<h3 id="為什麼不用-select--for-update">為什麼不用 <code class="language-plaintext highlighter-rouge">SELECT ... FOR UPDATE</code></h3>

<p>兩個理由：</p>

<ol>
  <li>鎖 <code class="language-plaintext highlighter-rouge">shop_id</code> 層級會把同一個 shop 的所有上傳串行化，上傳並發高的 shop 就卡住了。</li>
  <li>要鎖「不存在的 row」得用 gap lock / advisory lock，複雜度遠高於「rescue + retry」。</li>
</ol>

<p>對這種「自動改名避開衝突」的需求，樂觀鎖 (optimistic) + retry 幾乎永遠比悲觀鎖 (pessimistic) 好。</p>

<hr />

<h3 id="測試怎麼寫">測試怎麼寫</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">it</span> <span class="s1">'retries on unique index violation'</span> <span class="k">do</span>
  <span class="n">create</span><span class="p">(</span><span class="ss">:shop_product_photo</span><span class="p">,</span> <span class="ss">shop: </span><span class="n">shop</span><span class="p">,</span> <span class="ss">filename: </span><span class="s1">'photo_1'</span><span class="p">)</span>
  <span class="n">photo</span> <span class="o">=</span> <span class="n">build</span><span class="p">(</span><span class="ss">:shop_product_photo</span><span class="p">,</span> <span class="ss">shop: </span><span class="n">shop</span><span class="p">,</span> <span class="ss">filename: </span><span class="s1">'photo_1'</span><span class="p">)</span>
  <span class="n">photo</span><span class="p">.</span><span class="nf">save_with_unique_filename!</span>
  <span class="n">expect</span><span class="p">(</span><span class="n">photo</span><span class="p">.</span><span class="nf">filename</span><span class="p">).</span><span class="nf">to</span> <span class="n">eq</span><span class="p">(</span><span class="s1">'photo_1_2'</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">it</span> <span class="s1">'does not retry on unrelated unique index'</span> <span class="k">do</span>
  <span class="c1"># 模擬別的 index 撞到</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">photo</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:save!</span><span class="p">).</span><span class="nf">and_raise</span><span class="p">(</span>
    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s1">'Duplicate entry for index_on_email'</span><span class="p">)</span>
  <span class="p">)</span>
  <span class="n">expect</span> <span class="p">{</span> <span class="n">photo</span><span class="p">.</span><span class="nf">save_with_unique_filename!</span> <span class="p">}.</span><span class="nf">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">it</span> <span class="s1">'raises after MAX_RETRIES'</span> <span class="k">do</span>
  <span class="n">allow</span><span class="p">(</span><span class="n">photo</span><span class="p">).</span><span class="nf">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:save!</span><span class="p">).</span><span class="nf">and_raise</span><span class="p">(</span>
    <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s2">"... </span><span class="si">#{</span><span class="no">ShopProductPhoto</span><span class="o">::</span><span class="no">UNIQUE_FILENAME_INDEX</span><span class="si">}</span><span class="s2"> ..."</span><span class="p">)</span>
  <span class="p">)</span>
  <span class="n">expect</span> <span class="p">{</span> <span class="n">photo</span><span class="p">.</span><span class="nf">save_with_unique_filename!</span> <span class="p">}.</span><span class="nf">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">ActiveRecord</span><span class="o">::</span><span class="no">RecordNotUnique</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>至少覆蓋：初次成功、撞一次改名、撞到別的 index 不重試、超過上限拋出。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">validates_uniqueness_of</code> 不保證唯一性，DB unique index 才保證。</li>
  <li>Callback 幫忙的是 UX (常見場景提早 fail)，production 還是會撞 <code class="language-plaintext highlighter-rouge">RecordNotUnique</code>。</li>
  <li>處理方式：rescue <strong>特定</strong> index + retry + 改名 + 上限。</li>
  <li>primary/replica 分流的架構，重試時要強制走 primary 查資料。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Rails" /><category term="Rails" /><category term="ActiveRecord" /><category term="MySQL" /><category term="Concurrency" /><summary type="html"><![CDATA[Why validates_uniqueness_of is not enough, and how to retry safely]]></summary></entry><entry><title type="html">Rails migration 踩雷：MySQL int vs bigint FK</title><link href="https://gregning.github.io/rails/2026/04/24/rails-mysql-bigint-fk-pitfall/" rel="alternate" type="text/html" title="Rails migration 踩雷：MySQL int vs bigint FK" /><published>2026-04-24T17:00:00+00:00</published><updated>2026-04-24T17:00:00+00:00</updated><id>https://gregning.github.io/rails/2026/04/24/rails-mysql-bigint-fk-pitfall</id><content type="html" xml:base="https://gregning.github.io/rails/2026/04/24/rails-mysql-bigint-fk-pitfall/"><![CDATA[<h3 id="問題">問題</h3>

<p>當你在一個已經跑了很多年的 Rails + MySQL 專案加一張新表，然後很直覺地寫：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:shop_ai_credentials</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:shop</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>在新建的 local 環境可能沒事，但 deploy 到 production 會在 migration 那一步死掉，錯誤訊息類似：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mysql2::Error: Referencing column 'shop_id' and referenced column 'id'
in foreign key constraint ... are incompatible.
</code></pre></div></div>

<h3 id="原因">原因</h3>

<p><code class="language-plaintext highlighter-rouge">t.references :shop</code> 在 Rails 5.1+ 預設會建 <code class="language-plaintext highlighter-rouge">bigint</code>。但如果你的 <code class="language-plaintext highlighter-rouge">shops.id</code> 是很久以前建的，欄位型態很可能是 <code class="language-plaintext highlighter-rouge">int</code> 而不是 <code class="language-plaintext highlighter-rouge">bigint</code>。MySQL 建 foreign key 時會嚴格檢查兩邊欄位型態必須完全一致，<code class="language-plaintext highlighter-rouge">int</code> 跟 <code class="language-plaintext highlighter-rouge">bigint</code> 就會被擋下來。</p>

<p>local 可能沒事是因為你是用最新的 schema 從零跑 migration，<code class="language-plaintext highlighter-rouge">shops.id</code> 一開始就是 bigint。production 則是歷史遺物。</p>

<hr />

<h3 id="修法">修法</h3>

<p>有兩個方向：</p>

<p><strong>1. 不加 DB-level 的 FK，只在 model 層用 <code class="language-plaintext highlighter-rouge">belongs_to</code></strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:shop_ai_credentials</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">bigint</span> <span class="ss">:shop_id</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:provider</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>

<span class="n">add_index</span> <span class="ss">:shop_ai_credentials</span><span class="p">,</span> <span class="ss">:shop_id</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ShopAiCredential</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:shop</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Referential integrity 從 DB 移到 app 層。優點：不用動 <code class="language-plaintext highlighter-rouge">shops</code> 表、deploy 零風險。缺點：如果有別的系統 (data pipeline、直接跑 SQL 的人) 繞過 Rails 寫資料，就保證不了了。</p>

<p><strong>2. 先把 <code class="language-plaintext highlighter-rouge">shops.id</code> 升級成 bigint</strong></p>

<p>這是正解，但成本高。要：</p>

<ul>
  <li>確認 <code class="language-plaintext highlighter-rouge">shops.id</code> 沒有其他表以 <code class="language-plaintext highlighter-rouge">int</code> 型態參照它 (每個參照方都要一起升級，不然同樣撞 FK 型態錯誤)。</li>
  <li>跑一支 migration 改 column type。大表會鎖很久。</li>
  <li>測 replication 是否跟得上。</li>
</ul>

<p>小專案可以做，大表 + 多 FK 的老專案通常不值得。</p>

<hr />

<h3 id="為什麼不要留-foreign_key-true--rescue">為什麼不要留 <code class="language-plaintext highlighter-rouge">foreign_key: true</code> + rescue</h3>

<p>有些人會想「那我包 <code class="language-plaintext highlighter-rouge">begin/rescue</code> 先試看看」。不要。migration 失敗後 schema 會停在半套狀態，下一次 deploy 會更麻煩。decision 要在寫 migration 當下就做：<strong>要嘛確認兩邊都是 bigint，要嘛就不加 DB FK</strong>。</p>

<hr />

<h3 id="順便欄位-index-也要自己加">順便：欄位 index 也要自己加</h3>

<p><code class="language-plaintext highlighter-rouge">t.references</code> 會自動加 index，但 <code class="language-plaintext highlighter-rouge">t.bigint :shop_id</code> 不會。記得手動：</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add_index</span> <span class="ss">:shop_ai_credentials</span><span class="p">,</span> <span class="ss">:shop_id</span>
</code></pre></div></div>

<p>如果還有複合 unique index (ex: <code class="language-plaintext highlighter-rouge">[shop_id, provider]</code>)，<strong>單欄 <code class="language-plaintext highlighter-rouge">shop_id</code> index 還是要保留</strong>。MySQL 複合 index 只在前綴欄位可以被單欄查詢利用，但有些 ORM 產生的 query 不一定會打到前綴，留一個獨立的 <code class="language-plaintext highlighter-rouge">shop_id</code> index 最保險。</p>

<hr />

<h3 id="小結">小結</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">t.references</code> 在新專案很好用，老專案要先確認對面 <code class="language-plaintext highlighter-rouge">id</code> 欄位型態。</li>
  <li>用 <code class="language-plaintext highlighter-rouge">t.bigint :shop_id</code> + <code class="language-plaintext highlighter-rouge">belongs_to</code> 是<strong>最務實的妥協</strong>：DB 不加 FK、model 負責關聯。</li>
  <li>別忘了手動加 <code class="language-plaintext highlighter-rouge">add_index</code> 跟複合 unique index。</li>
</ul>]]></content><author><name>Greg</name></author><category term="Rails" /><category term="Rails" /><category term="MySQL" /><category term="Migration" /><summary type="html"><![CDATA[Why t.references + add_foreign_key can fail on legacy MySQL schemas]]></summary></entry><entry><title type="html">Expo Deep Link 與 Universal Links 筆記</title><link href="https://gregning.github.io/react-native/2026/04/24/expo-deep-link-universal-links/" rel="alternate" type="text/html" title="Expo Deep Link 與 Universal Links 筆記" /><published>2026-04-24T16:00:00+00:00</published><updated>2026-04-24T16:00:00+00:00</updated><id>https://gregning.github.io/react-native/2026/04/24/expo-deep-link-universal-links</id><content type="html" xml:base="https://gregning.github.io/react-native/2026/04/24/expo-deep-link-universal-links/"><![CDATA[<h3 id="名詞先分清楚">名詞先分清楚</h3>

<p>這幾個詞常混用，先分清楚：</p>

<ol>
  <li><strong>Deep Link</strong> — 任何可以「打開 app 到特定畫面」的連結都算，包含 custom scheme (<code class="language-plaintext highlighter-rouge">myapp://order/123</code>) 與 https link。</li>
  <li><strong>Universal Links</strong> (iOS) — 用 <code class="language-plaintext highlighter-rouge">https://</code> 連結打開 app 的機制，沒裝 app 就 fallback 到網頁。</li>
  <li><strong>App Links</strong> (Android) — 類似 Universal Links 的 Android 版本，需要 Digital Asset Links 驗證。</li>
  <li><strong>Custom URL Scheme</strong> — 早期的 <code class="language-plaintext highlighter-rouge">myapp://</code> 作法，不需 domain 驗證，但 iOS/Android 都不保證安全。</li>
</ol>

<p>在 Expo 裡，三種都可以做，只是設定方式不一樣。</p>

<hr />

<h3 id="custom-scheme-最簡單">Custom Scheme (最簡單)</h3>

<p><code class="language-plaintext highlighter-rouge">app.json</code> / <code class="language-plaintext highlighter-rouge">app.config.js</code> 設定：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"expo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"scheme"</span><span class="p">:</span><span class="w"> </span><span class="s2">"myapp"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>打開 <code class="language-plaintext highlighter-rouge">myapp://order/123</code> 就會進到 app。測試用可以，但正式環境建議改用 Universal Links / App Links，因為 custom scheme 在 email、SMS、社群平台常會被攔截或不可點。</p>

<hr />

<h3 id="ios-universal-links">iOS Universal Links</h3>

<p>要讓 <code class="language-plaintext highlighter-rouge">https://example.com/order/123</code> 直接打開 app，需要三件事：</p>

<ol>
  <li>
    <p><strong>Associated Domains capability</strong> 要打開。Expo 設定：</p>

    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w"> </span><span class="p">{</span><span class="w">
   </span><span class="nl">"expo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
     </span><span class="nl">"ios"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
       </span><span class="nl">"associatedDomains"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"applinks:example.com"</span><span class="p">]</span><span class="w">
     </span><span class="p">}</span><span class="w">
   </span><span class="p">}</span><span class="w">
 </span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>Apple App Site Association 檔案</strong> 要放在：</p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> https://example.com/.well-known/apple-app-site-association
</code></pre></div>    </div>

    <p>內容：</p>

    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w"> </span><span class="p">{</span><span class="w">
   </span><span class="nl">"applinks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
     </span><span class="nl">"apps"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
     </span><span class="nl">"details"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
       </span><span class="p">{</span><span class="w">
         </span><span class="nl">"appID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"TEAMID.com.yourcompany.yourapp"</span><span class="p">,</span><span class="w">
         </span><span class="nl">"paths"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/order/*"</span><span class="p">,</span><span class="w"> </span><span class="s2">"/product/*"</span><span class="p">]</span><span class="w">
       </span><span class="p">}</span><span class="w">
     </span><span class="p">]</span><span class="w">
   </span><span class="p">}</span><span class="w">
 </span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>

    <p>注意：這個檔案 <strong>不能加副檔名</strong>，而且必須 <code class="language-plaintext highlighter-rouge">Content-Type: application/json</code>，HTTPS 回 200。</p>
  </li>
  <li>
    <p>iOS 第一次啟動 app 時會 fetch 這個檔案，之後就認得這個 domain。</p>
  </li>
</ol>

<hr />

<h3 id="android-app-links">Android App Links</h3>

<p>Expo 設定：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"expo"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"android"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"intentFilters"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"VIEW"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"autoVerify"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
          </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="p">{</span><span class="w"> </span><span class="nl">"scheme"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https"</span><span class="p">,</span><span class="w"> </span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="s2">"example.com"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pathPrefix"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/order"</span><span class="w"> </span><span class="p">}</span><span class="w">
          </span><span class="p">],</span><span class="w">
          </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"BROWSABLE"</span><span class="p">,</span><span class="w"> </span><span class="s2">"DEFAULT"</span><span class="p">]</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>然後 web 端放：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://example.com/.well-known/assetlinks.json
</code></pre></div></div>

<p>內容：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w">
    </span><span class="nl">"relation"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"delegate_permission/common.handle_all_urls"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"target"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"namespace"</span><span class="p">:</span><span class="w"> </span><span class="s2">"android_app"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"package_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.yourcompany.yourapp"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"sha256_cert_fingerprints"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"AA:BB:CC:..."</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sha256_cert_fingerprints</code> 是你的簽章憑證 SHA-256 指紋，用 <code class="language-plaintext highlighter-rouge">eas credentials</code> 可以查。</p>

<hr />

<h3 id="路由側處理-expo-router">路由側處理 (Expo Router)</h3>

<p>Expo Router 會自動把 https/app link 的 pathname 對應到 route。例如 <code class="language-plaintext highlighter-rouge">/order/123</code> 打開 app，會進 <code class="language-plaintext highlighter-rouge">app/order/[id].tsx</code>，完全不用額外寫 linking config。</p>

<p>如果沒用 Expo Router，就要自己用 <code class="language-plaintext highlighter-rouge">Linking.getInitialURL()</code> 跟 <code class="language-plaintext highlighter-rouge">Linking.addEventListener('url', ...)</code> 處理：</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="o">*</span> <span class="k">as</span> <span class="nx">Linking</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">expo-linking</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">useEffect</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nx">useDeepLink</span><span class="p">(</span><span class="nx">onUrl</span><span class="p">:</span> <span class="p">(</span><span class="nx">url</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">Linking</span><span class="p">.</span><span class="nx">getInitialURL</span><span class="p">().</span><span class="nx">then</span><span class="p">((</span><span class="nx">url</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">url</span><span class="p">)</span> <span class="nx">onUrl</span><span class="p">(</span><span class="nx">url</span><span class="p">);</span>
    <span class="p">});</span>
    <span class="kd">const</span> <span class="nx">sub</span> <span class="o">=</span> <span class="nx">Linking</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">url</span><span class="dl">'</span><span class="p">,</span> <span class="p">({</span> <span class="nx">url</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="nx">onUrl</span><span class="p">(</span><span class="nx">url</span><span class="p">));</span>
    <span class="k">return</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">sub</span><span class="p">.</span><span class="nx">remove</span><span class="p">();</span>
  <span class="p">},</span> <span class="p">[</span><span class="nx">onUrl</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h3 id="常見踩雷">常見踩雷</h3>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">apple-app-site-association</code> 要放在 root domain</strong>，不能放 subdirectory。</li>
  <li><strong>iOS 會 cache</strong>，改完檔案之後不一定馬上生效；重裝 app 最保險。</li>
  <li><strong>Android autoVerify 失敗 = 走不到 deep link</strong>。要在系統 log 看 <code class="language-plaintext highlighter-rouge">PackageManager</code> 的驗證結果，常見原因是 assetlinks.json 回 301、回 HTML、Content-Type 錯、或 SHA-256 指紋對不上 (debug vs release key)。</li>
  <li><strong>iOS 上從 Safari 手動打 URL 不會觸發 Universal Link</strong>，要從別的 app (iMessage、Notes) 點才行。這不是 bug，是 Apple 設計。</li>
  <li><strong>沒裝 app 時的 fallback</strong> — 兩邊 OS 都會自動開瀏覽器載入該 https URL，所以 web 端最好也能處理同一個 path。</li>
</ol>

<hr />

<h3 id="小結">小結</h3>

<p>Deep link 要打通的話，app 端設定其實不多，主要麻煩都在 <strong>domain 這側的兩個檔案</strong> (<code class="language-plaintext highlighter-rouge">apple-app-site-association</code> 跟 <code class="language-plaintext highlighter-rouge">assetlinks.json</code>)，跟驗證流程。第一次踩都會在這裡花最多時間，設定好之後就是單純的路由對應問題了。</p>]]></content><author><name>Greg</name></author><category term="React-Native" /><category term="Expo" /><category term="React-Native" /><category term="Deep-Link" /><category term="Universal-Links" /><summary type="html"><![CDATA[Deep link, Universal Links, App Links in Expo]]></summary></entry></feed>