<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Firestore Hands-on 操作路線 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/</link><description>Recent content in Firestore Hands-on 操作路線 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 16 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/index.xml" rel="self" type="application/rss+xml"/><item><title>Firestore Distributed Counter Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/distributed-counter-lab/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/distributed-counter-lab/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線&lt;/a> 的 lab，實作 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">distributed counter 高頻寫入&lt;/a> deep article 的機制。前置環境見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart&lt;/a>。&lt;/p>&lt;/blockquote>
&lt;p>Firestore distributed counter lab 的核心責任是把「分片計數」從概念變成可觀察的寫入分佈與彙總結果。這個 lab 在 emulator 上建立 N 個 shard、隨機分片寫入大量 increment、檢查寫入是否均勻打散到各 shard、再讀取彙總驗證總和正確。&lt;/p>
&lt;p>本文的驗收標準是：你能跑出一個 sharded counter、看到 N 個 shard 各自累積了大致均勻的 partial count、彙總後等於總寫入次數，並理解 emulator 能驗什麼、不能驗什麼。&lt;/p>
&lt;h2 id="先講清楚-emulator-的邊界">先講清楚 emulator 的邊界&lt;/h2>
&lt;p>這個 lab 驗證的是&lt;strong>分片計數的機制正確性&lt;/strong>：寫入是否均勻分佈、彙總是否等於總和、讀取要讀幾個 document。它不驗證的是 &lt;strong>contention 本身&lt;/strong>——emulator 不強制 production 的單 document 持續寫入軟上限，所以「不分片會寫爆」這件事在 emulator 跑不出來。contention 是 production 的規模特性，要在雲端真實負載下才會出現。&lt;/p>
&lt;p>這個分界本身是要學的判讀：emulator 證明「分片計數做對了」，雲端負載測試才證明「不分片會撞牆」。把兩者混為一談會誤以為 emulator 全綠就代表 production 安全。&lt;/p>
&lt;h2 id="lab-環境">Lab 環境&lt;/h2>
&lt;p>沿用 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart&lt;/a> 的工作區與 emulator。確認 emulator 在跑（另一個 terminal）。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/firestore-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1"># 確認 emulator 已啟動：firebase emulators:start --only firestore --project demo-firestore-lab&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">FIRESTORE_EMULATOR_HOST&lt;/span>&lt;span class="o">=&lt;/span>localhost:8080&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="實作-sharded-counter">實作 sharded counter&lt;/h2>
&lt;p>counter 的核心責任是把一個邏輯計數拆成 N 個 shard document。寫入時隨機挑 shard &lt;code>increment(1)&lt;/code>，讀取時加總所有 shard。這份 script 用 admin SDK 直接對 emulator 操作。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">cat &amp;gt; counter.js &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;JS&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">const admin = require(&amp;#39;firebase-admin&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s">admin.initializeApp({ projectId: &amp;#39;demo-firestore-lab&amp;#39; });
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s">const db = admin.firestore();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">const FieldValue = admin.firestore.FieldValue;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s">const NUM_SHARDS = 10;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s">const counterRef = db.collection(&amp;#39;counters&amp;#39;).doc(&amp;#39;likes&amp;#39;);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s">async function createCounter() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s"> const batch = db.batch();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s"> for (let i = 0; i &amp;lt; NUM_SHARDS; i++) {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s"> batch.set(counterRef.collection(&amp;#39;shards&amp;#39;).doc(String(i)), { count: 0 });
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s"> await batch.commit();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s">async function incrementOnce() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s"> const shardId = Math.floor(Math.random() * NUM_SHARDS);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="s"> await counterRef.collection(&amp;#39;shards&amp;#39;).doc(String(shardId))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="s"> .set({ count: FieldValue.increment(1) }, { merge: true });
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="s">async function getCount() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="s"> const snap = await counterRef.collection(&amp;#39;shards&amp;#39;).get();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="s"> let total = 0;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="s"> const perShard = {};
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">&lt;span class="s"> snap.forEach((s) =&amp;gt; { perShard[s.id] = s.data().count; total += s.data().count; });
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">&lt;span class="s"> return { total, perShard };
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="s">module.exports = { createCounter, incrementOnce, getCount, NUM_SHARDS };
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">&lt;span class="s">JS&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個設計點對應 deep article：用 &lt;code>FieldValue.increment(1)&lt;/code> 而非讀-改-寫（避開 race）；隨機選 shard 讓寫入均勻打散；讀取要讀 N 個 shard 加總（這是分片的代價）。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a> 的 lab，實作 <a href="/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">distributed counter 高頻寫入</a> deep article 的機制。前置環境見 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart</a>。</p></blockquote>
<p>Firestore distributed counter lab 的核心責任是把「分片計數」從概念變成可觀察的寫入分佈與彙總結果。這個 lab 在 emulator 上建立 N 個 shard、隨機分片寫入大量 increment、檢查寫入是否均勻打散到各 shard、再讀取彙總驗證總和正確。</p>
<p>本文的驗收標準是：你能跑出一個 sharded counter、看到 N 個 shard 各自累積了大致均勻的 partial count、彙總後等於總寫入次數，並理解 emulator 能驗什麼、不能驗什麼。</p>
<h2 id="先講清楚-emulator-的邊界">先講清楚 emulator 的邊界</h2>
<p>這個 lab 驗證的是<strong>分片計數的機制正確性</strong>：寫入是否均勻分佈、彙總是否等於總和、讀取要讀幾個 document。它不驗證的是 <strong>contention 本身</strong>——emulator 不強制 production 的單 document 持續寫入軟上限，所以「不分片會寫爆」這件事在 emulator 跑不出來。contention 是 production 的規模特性，要在雲端真實負載下才會出現。</p>
<p>這個分界本身是要學的判讀：emulator 證明「分片計數做對了」，雲端負載測試才證明「不分片會撞牆」。把兩者混為一談會誤以為 emulator 全綠就代表 production 安全。</p>
<h2 id="lab-環境">Lab 環境</h2>
<p>沿用 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart</a> 的工作區與 emulator。確認 emulator 在跑（另一個 terminal）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp/firestore-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 確認 emulator 已啟動：firebase emulators:start --only firestore --project demo-firestore-lab</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">export</span> <span class="nv">FIRESTORE_EMULATOR_HOST</span><span class="o">=</span>localhost:8080</span></span></code></pre></div><h2 id="實作-sharded-counter">實作 sharded counter</h2>
<p>counter 的核心責任是把一個邏輯計數拆成 N 個 shard document。寫入時隨機挑 shard <code>increment(1)</code>，讀取時加總所有 shard。這份 script 用 admin SDK 直接對 emulator 操作。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; counter.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const admin = require(&#39;firebase-admin&#39;);
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">admin.initializeApp({ projectId: &#39;demo-firestore-lab&#39; });
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">const db = admin.firestore();
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">const FieldValue = admin.firestore.FieldValue;
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">const NUM_SHARDS = 10;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">const counterRef = db.collection(&#39;counters&#39;).doc(&#39;likes&#39;);
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">async function createCounter() {
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">  const batch = db.batch();
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">  for (let i = 0; i &lt; NUM_SHARDS; i++) {
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">    batch.set(counterRef.collection(&#39;shards&#39;).doc(String(i)), { count: 0 });
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">  await batch.commit();
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">async function incrementOnce() {
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">  const shardId = Math.floor(Math.random() * NUM_SHARDS);
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">  await counterRef.collection(&#39;shards&#39;).doc(String(shardId))
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">    .set({ count: FieldValue.increment(1) }, { merge: true });
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">async function getCount() {
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">  const snap = await counterRef.collection(&#39;shards&#39;).get();
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="s">  let total = 0;
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="s">  const perShard = {};
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="s">  snap.forEach((s) =&gt; { perShard[s.id] = s.data().count; total += s.data().count; });
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="s">  return { total, perShard };
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="s">module.exports = { createCounter, incrementOnce, getCount, NUM_SHARDS };
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="s">JS</span></span></span></code></pre></div><p>三個設計點對應 deep article：用 <code>FieldValue.increment(1)</code> 而非讀-改-寫（避開 race）；隨機選 shard 讓寫入均勻打散；讀取要讀 N 個 shard 加總（這是分片的代價）。</p>
<h2 id="跑寫入並觀察分佈">跑寫入並觀察分佈</h2>
<p>driver 的核心責任是製造大量 increment、然後檢查寫入是否均勻落在各 shard。均勻分佈是分片有效的前提——若 shard 選擇有偏，熱點會在某幾個 shard 復現。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; run.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const { createCounter, incrementOnce, getCount, NUM_SHARDS } = require(&#39;./counter&#39;);
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">const TOTAL_WRITES = 1000;
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">async function main() {
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  await createCounter();
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">  console.log(`created ${NUM_SHARDS} shards`);
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  // 製造 1000 次 increment
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">  const tasks = [];
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">  for (let i = 0; i &lt; TOTAL_WRITES; i++) tasks.push(incrementOnce());
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">  await Promise.all(tasks);
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">  const { total, perShard } = await getCount();
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">  console.log(&#39;per-shard counts:&#39;, perShard);
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">  console.log(`total = ${total} (expected ${TOTAL_WRITES})`);
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">  // 均勻度檢查：每個 shard 期望 ~100，看極差
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">  const counts = Object.values(perShard);
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">  const min = Math.min(...counts), max = Math.max(...counts);
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">  console.log(`min=${min} max=${max} spread=${max - min} (expected mean ~${TOTAL_WRITES / NUM_SHARDS})`);
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">main().then(() =&gt; process.exit(0));
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">JS</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="nb">export</span> <span class="nv">FIRESTORE_EMULATOR_HOST</span><span class="o">=</span>localhost:8080
</span></span><span class="line"><span class="ln">28</span><span class="cl">node run.js</span></span></code></pre></div><p>預期輸出類似（實際數字每次隨機分佈而異）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">created 10 shards
</span></span><span class="line"><span class="ln">2</span><span class="cl">per-shard counts: { &#39;0&#39;: 98, &#39;1&#39;: 105, &#39;2&#39;: 92, ... }
</span></span><span class="line"><span class="ln">3</span><span class="cl">total = 1000 (expected 1000)
</span></span><span class="line"><span class="ln">4</span><span class="cl">min=88 max=112 spread=24 (expected mean ~100)</span></span></code></pre></div><p>兩個驗收點：<code>total</code> 等於總寫入次數（彙總正確、沒有 increment 遺失），以及各 shard 的 count 大致落在均值附近（隨機分佈均勻、沒有單一 shard 吸走大部分寫入）。</p>
<h2 id="對照實驗讀取成本隨-shard-數成長">對照實驗：讀取成本隨 shard 數成長</h2>
<p>讀取的核心代價是讀 N 個 document。把 <code>NUM_SHARDS</code> 改大（例如 100）重跑，<code>getCount</code> 要讀的 document 從 10 變 100——這就是 deep article 講的「寫入便宜了、讀取乘以 N」的取捨。在 production 這直接反映成 read 計費。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 編輯 counter.js 把 NUM_SHARDS 改為 100、重跑 run.js</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 觀察 per-shard counts 物件變成 100 個 key、getCount 讀取量 10x</span></span></span></code></pre></div><p>這個對照讓「shard 數是寫入分散與讀取成本的取捨」從文字變成可觀察：多 shard 寫入更分散（每 shard 更少），但讀取要加總更多筆。高寫入高讀取的場景該配 summary 彙總（deep article 的進階手段），而非無限加 shard。</p>
<h2 id="artifact-與驗收">Artifact 與驗收</h2>
<table>
  <thead>
      <tr>
          <th>Artifact</th>
          <th>來源</th>
          <th>驗收</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>counter 實作</td>
          <td><code>counter.js</code></td>
          <td><code>increment</code> 分片寫入 + 彙總讀取</td>
      </tr>
      <tr>
          <td>寫入分佈</td>
          <td><code>run.js</code> output</td>
          <td>total = 寫入次數、各 shard 均勻</td>
      </tr>
      <tr>
          <td>讀寫取捨</td>
          <td>NUM_SHARDS 對照</td>
          <td>shard 數↑ → 讀取 document 數↑</td>
      </tr>
  </tbody>
</table>
<h2 id="回到-production-判讀">回到 production 判讀</h2>
<p>emulator lab 證明了機制正確，但三個 production 判讀要回雲端確認：單 document 寫入軟上限（決定 shard 數要多少）、read 計費（決定 shard 數別太多 / 要不要 summary）、shard 選擇在真實流量下是否仍均勻。把 emulator 的機制驗證當第一道關，production 的容量與成本判讀見 <a href="/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/#%e5%ae%b9%e9%87%8f%e8%88%87%e8%a7%80%e6%b8%acshard-%e6%95%b8%e7%9a%84%e4%bc%b0%e7%ae%97%e8%88%87%e7%9b%a3%e6%8e%a7" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">deep article 的容量段</a>。</p>
<h2 id="cleanup">Cleanup</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 停 emulator（Ctrl-C）或清整個工作區</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf /tmp/firestore-lab</span></span></code></pre></div><h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a></li>
<li>Deep article：<a href="/blog/backend/01-database/vendors/firestore/distributed-counter-high-frequency-write/" data-link-title="Firestore 高頻寫入與 distributed counter：單 document contention 邊界與分片計數" data-link-desc="Firestore 單一 document 有持續寫入的軟上限、高頻計數寫爆 contention 是常見事故；本文展開寫入 contention 的成因、distributed counter 分片計數的實作與讀取彙總、shard 數量與讀寫成本的取捨、五個高頻寫入踩坑，以及計數需求超過分片能處理時改走外部聚合的邊界">高頻寫入與 distributed counter</a></li>
<li>一致性邊界：<a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">1.3 transaction 與一致性邊界</a></li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/solutions/counters">Distributed counters</a>、<a href="https://firebase.google.com/docs/firestore/best-practices">Firestore best practices</a></li>
</ul>
]]></content:encoded></item><item><title>Firestore Local Emulator Quickstart</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線&lt;/a> 的基礎 lab。指令以 &lt;a href="https://firebase.google.com/docs/cli">Firebase CLI 文件&lt;/a> 與 &lt;a href="https://firebase.google.com/docs/emulator-suite">Emulator Suite 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;p>Firestore local emulator quickstart 的核心責任是建立後續 Security Rules 測試與 distributed counter lab 共用的本地環境。這個 lab 把 Firestore 從抽象服務轉成可觀察的 emulator、規則檔、seed 資料與 query 結果，全程不碰雲端專案。&lt;/p>
&lt;p>本文的驗收標準是：你能在本地啟動 Firestore emulator、用 admin SDK 寫入並查詢一組 seed 資料、看到 emulator UI 裡的資料，並知道 cleanup 路徑。&lt;/p>
&lt;h2 id="lab-環境與前置">Lab 環境與前置&lt;/h2>
&lt;p>Lab 在本地資料夾跑，需要 Node.js 與 Firebase CLI。以下命令建立一個可刪除的工作區並裝好工具。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p /tmp/firestore-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/firestore-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Firebase CLI（已裝可跳過）；用 npx 也可避免全域安裝&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">npm install -g firebase-tools
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 本 lab 的 Node 依賴&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">npm init -y
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">npm install firebase-admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>emulator 需要 Java runtime（Firestore emulator 跑在 JVM 上）。&lt;code>java -version&lt;/code> 確認存在；缺的話先裝 JDK 再繼續。驗收 artifact 是 &lt;code>/tmp/firestore-lab&lt;/code> 工作區。&lt;/p>
&lt;h2 id="emulator-設定">Emulator 設定&lt;/h2>
&lt;p>&lt;code>firebase.json&lt;/code> 的核心責任是宣告要啟動哪些 emulator 與對應 port。這裡只開 Firestore 與 UI，不需要真實 Firebase 專案——emulator 用一個 demo project id 即可，&lt;code>demo-&lt;/code> 前綴讓 CLI 知道這是純本地、不連雲端。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">cat &amp;gt; firebase.json &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;JSON&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;#34;emulators&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;#34;firestore&amp;#34;: { &amp;#34;port&amp;#34;: 8080 },
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;#34;ui&amp;#34;: { &amp;#34;enabled&amp;#34;: true, &amp;#34;port&amp;#34;: 4000 }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s"> },
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;#34;firestore&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;#34;rules&amp;#34;: &amp;#34;firestore.rules&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s">JSON&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="baseline-規則">Baseline 規則&lt;/h2>
&lt;p>&lt;code>firestore.rules&lt;/code> 的核心責任是定義授權。Quickstart 先用一組明確的 owner-scoped 規則（不是 &lt;code>allow read, write: if true&lt;/code>，那是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">deep article Case 1&lt;/a> 的漏洞）。這份規則後續在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/" data-link-title="Firestore Security Rules Test Lab" data-link-desc="用 @firebase/rules-unit-testing 在 emulator 上把 Security Rules 寫成自動化測試：放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕四類斷言、firebase emulators:exec 在 CI 跑、把規則測試接進 release gate">Security Rules test lab&lt;/a> 會被測試覆蓋。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a> 的基礎 lab。指令以 <a href="https://firebase.google.com/docs/cli">Firebase CLI 文件</a> 與 <a href="https://firebase.google.com/docs/emulator-suite">Emulator Suite 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<p>Firestore local emulator quickstart 的核心責任是建立後續 Security Rules 測試與 distributed counter lab 共用的本地環境。這個 lab 把 Firestore 從抽象服務轉成可觀察的 emulator、規則檔、seed 資料與 query 結果，全程不碰雲端專案。</p>
<p>本文的驗收標準是：你能在本地啟動 Firestore emulator、用 admin SDK 寫入並查詢一組 seed 資料、看到 emulator UI 裡的資料，並知道 cleanup 路徑。</p>
<h2 id="lab-環境與前置">Lab 環境與前置</h2>
<p>Lab 在本地資料夾跑，需要 Node.js 與 Firebase CLI。以下命令建立一個可刪除的工作區並裝好工具。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">mkdir -p /tmp/firestore-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">cd</span> /tmp/firestore-lab
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Firebase CLI（已裝可跳過）；用 npx 也可避免全域安裝</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">npm install -g firebase-tools
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 本 lab 的 Node 依賴</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">npm init -y
</span></span><span class="line"><span class="ln">9</span><span class="cl">npm install firebase-admin</span></span></code></pre></div><p>emulator 需要 Java runtime（Firestore emulator 跑在 JVM 上）。<code>java -version</code> 確認存在；缺的話先裝 JDK 再繼續。驗收 artifact 是 <code>/tmp/firestore-lab</code> 工作區。</p>
<h2 id="emulator-設定">Emulator 設定</h2>
<p><code>firebase.json</code> 的核心責任是宣告要啟動哪些 emulator 與對應 port。這裡只開 Firestore 與 UI，不需要真實 Firebase 專案——emulator 用一個 demo project id 即可，<code>demo-</code> 前綴讓 CLI 知道這是純本地、不連雲端。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; firebase.json <span class="s">&lt;&lt;&#39;JSON&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">{
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">  &#34;emulators&#34;: {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">    &#34;firestore&#34;: { &#34;port&#34;: 8080 },
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">    &#34;ui&#34;: { &#34;enabled&#34;: true, &#34;port&#34;: 4000 }
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">  },
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  &#34;firestore&#34;: {
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    &#34;rules&#34;: &#34;firestore.rules&#34;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">JSON</span></span></span></code></pre></div><h2 id="baseline-規則">Baseline 規則</h2>
<p><code>firestore.rules</code> 的核心責任是定義授權。Quickstart 先用一組明確的 owner-scoped 規則（不是 <code>allow read, write: if true</code>，那是 <a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">deep article Case 1</a> 的漏洞）。這份規則後續在 <a href="/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/" data-link-title="Firestore Security Rules Test Lab" data-link-desc="用 @firebase/rules-unit-testing 在 emulator 上把 Security Rules 寫成自動化測試：放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕四類斷言、firebase emulators:exec 在 CI 跑、把規則測試接進 release gate">Security Rules test lab</a> 會被測試覆蓋。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; firestore.rules <span class="s">&lt;&lt;&#39;RULES&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">rules_version = &#39;2&#39;;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">service cloud.firestore {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">  match /databases/{database}/documents {
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">    match /notes/{noteId} {
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">      allow read: if request.auth != null
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">                  &amp;&amp; resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">      allow create: if request.auth != null
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">                    &amp;&amp; request.resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">      allow update, delete: if request.auth != null
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">                            &amp;&amp; resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">RULES</span></span></span></code></pre></div><h2 id="啟動-emulator">啟動 emulator</h2>
<p>啟動 emulator 的核心責任是讓本地有一個可寫可查的 Firestore。用 demo project id 啟動，emulator UI 在 <code>http://localhost:4000</code> 可看到資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">firebase emulators:start --only firestore --project demo-firestore-lab</span></span></code></pre></div><p>這個指令會 foreground 跑住 emulator。保持它開著，另開一個 terminal 做 seed 與 query。終端輸出會印出 Firestore emulator 的位址（預設 <code>localhost:8080</code>）與 UI 位址。</p>
<h2 id="seed-資料admin-sdk-繞過規則">Seed 資料（admin SDK 繞過規則）</h2>
<p>Seed 的核心責任是建立可重跑的測試資料。admin SDK 連到 emulator 時繞過 Security Rules（模擬後端的特權寫入），適合種資料。關鍵是設 <code>FIRESTORE_EMULATOR_HOST</code> 環境變數——有了它，admin SDK 的寫入全部導向 emulator、不需要任何雲端 credential。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; seed.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const admin = require(&#39;firebase-admin&#39;);
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">admin.initializeApp({ projectId: &#39;demo-firestore-lab&#39; });
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">const db = admin.firestore();
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">async function main() {
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  await db.collection(&#39;notes&#39;).doc(&#39;n1&#39;).set({
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    ownerId: &#39;alice&#39;, text: &#39;Alice first note&#39;, createdAt: Date.now(),
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  await db.collection(&#39;notes&#39;).doc(&#39;n2&#39;).set({
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">    ownerId: &#39;bob&#39;, text: &#39;Bob first note&#39;, createdAt: Date.now(),
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">  console.log(&#39;seeded 2 notes&#39;);
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">main().then(() =&gt; process.exit(0));
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">JS</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># 在新 terminal、同 lab 目錄下</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nb">export</span> <span class="nv">FIRESTORE_EMULATOR_HOST</span><span class="o">=</span>localhost:8080
</span></span><span class="line"><span class="ln">20</span><span class="cl">node seed.js</span></span></code></pre></div><p>預期輸出 <code>seeded 2 notes</code>。打開 <code>http://localhost:4000/firestore</code> 應看到 <code>notes</code> collection 下兩筆 document。</p>
<h2 id="query-baseline">Query baseline</h2>
<p>Query 的核心責任是確認資料可讀、access pattern 入口可用。admin SDK 同樣繞過規則，這裡驗證的是資料與查詢本身（規則的放行 / 拒絕在下一個 lab 用 client context 驗）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; query.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const admin = require(&#39;firebase-admin&#39;);
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">admin.initializeApp({ projectId: &#39;demo-firestore-lab&#39; });
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">const db = admin.firestore();
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">async function main() {
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">  const snap = await db.collection(&#39;notes&#39;)
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    .where(&#39;ownerId&#39;, &#39;==&#39;, &#39;alice&#39;).get();
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">  console.log(`alice notes: ${snap.size}`);
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">  snap.forEach((d) =&gt; console.log(d.id, d.data().text));
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">main().then(() =&gt; process.exit(0));
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">JS</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nb">export</span> <span class="nv">FIRESTORE_EMULATOR_HOST</span><span class="o">=</span>localhost:8080
</span></span><span class="line"><span class="ln">16</span><span class="cl">node query.js</span></span></code></pre></div><p>預期輸出 <code>alice notes: 1</code> 與 <code>n1 Alice first note</code>。這證明 <code>where('ownerId', '==', ...)</code> 的 access pattern 成立——它也正是 client 端要自帶、好讓 owner-scoped 規則放行的查詢條件。</p>
<h2 id="artifact-與驗收">Artifact 與驗收</h2>
<table>
  <thead>
      <tr>
          <th>Artifact</th>
          <th>路徑 / 來源</th>
          <th>驗收</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>emulator config</td>
          <td><code>firebase.json</code></td>
          <td>Firestore + UI port 宣告</td>
      </tr>
      <tr>
          <td>規則檔</td>
          <td><code>firestore.rules</code></td>
          <td>owner-scoped、非 <code>if true</code></td>
      </tr>
      <tr>
          <td>seed 結果</td>
          <td><code>seed.js</code> output + UI</td>
          <td><code>notes/n1</code>、<code>notes/n2</code> 存在</td>
      </tr>
      <tr>
          <td>query 結果</td>
          <td><code>query.js</code> output</td>
          <td><code>alice notes: 1</code></td>
      </tr>
  </tbody>
</table>
<h2 id="cleanup">Cleanup</h2>
<p>Cleanup 的核心責任是讓 lab 可重跑。emulator 的資料在 process 結束時預設不持久化（除非設了 <code>--export-on-exit</code>），所以停掉 emulator 等於清空資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 停掉 emulator：在 emulator terminal 按 Ctrl-C</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 移除整個工作區</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rm -rf /tmp/firestore-lab</span></span></code></pre></div><p>若想保留 emulator 資料跨 session，啟動時加 <code>--import=./data --export-on-exit=./data</code>；lab 預設不持久化，保持每次乾淨起步。</p>
<p>完成本篇後，下一步進 <a href="/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/" data-link-title="Firestore Security Rules Test Lab" data-link-desc="用 @firebase/rules-unit-testing 在 emulator 上把 Security Rules 寫成自動化測試：放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕四類斷言、firebase emulators:exec 在 CI 跑、把規則測試接進 release gate">Security Rules test lab</a>（把上面的規則寫成自動化測試）或 <a href="/blog/backend/01-database/vendors/firestore/hands-on/distributed-counter-lab/" data-link-title="Firestore Distributed Counter Lab" data-link-desc="在 emulator 上實作 distributed counter：建立 N 個 shard、隨機分片寫入、觀察 shard 分佈是否均勻、讀取彙總驗證總和正確，並說明 contention 本身是 emulator 不模擬的 production 特性">Distributed counter lab</a>。</p>
<h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a></li>
<li>Deep article：<a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模</a></li>
<li>官方：<a href="https://firebase.google.com/docs/cli">Install Firebase CLI</a>、<a href="https://firebase.google.com/docs/emulator-suite/connect_firestore">Connect to Firestore emulator</a></li>
</ul>
]]></content:encoded></item><item><title>Firestore Security Rules Test Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線&lt;/a> 的 lab，實作 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模&lt;/a> deep article 的測試方法。前置環境見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart&lt;/a>。測試 API 以 &lt;a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;p>Firestore Security Rules test lab 的核心責任是把授權規則變成可自動驗證的測試。規則是 client 直連模型的整個控制面，改一條就要證明沒開新洞——這個 lab 用 &lt;code>@firebase/rules-unit-testing&lt;/code> 在 emulator 上對規則跑斷言，產出可接進 CI 與 release gate 的測試 evidence。&lt;/p>
&lt;p>本文的驗收標準是：你能對一組規則寫出「放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕」四類斷言、用 &lt;code>firebase emulators:exec&lt;/code> 一鍵跑完、並看到 &lt;code>assertFails&lt;/code> 確實證明該擋的有擋住。&lt;/p>
&lt;h2 id="lab-環境與依賴">Lab 環境與依賴&lt;/h2>
&lt;p>沿用 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart&lt;/a> 的工作區與 &lt;code>firebase.json&lt;/code> / &lt;code>firestore.rules&lt;/code>。再裝測試依賴。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/firestore-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">npm install --save-dev @firebase/rules-unit-testing firebase jest&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>驗收前置是 &lt;code>firestore.rules&lt;/code> 存在（quickstart 已建立 owner-scoped 規則）與 &lt;code>firebase.json&lt;/code> 宣告了 Firestore emulator。&lt;/p>
&lt;h2 id="升級規則加入欄位竄改防護">升級規則：加入欄位竄改防護&lt;/h2>
&lt;p>quickstart 的規則擋了越權讀寫，但還沒擋「owner 改自己 note 時偷改 &lt;code>ownerId&lt;/code> 把資料轉走」。先把規則升級到帶欄位白名單，讓測試有更多面向可驗。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">cat &amp;gt; firestore.rules &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;RULES&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">rules_version = &amp;#39;2&amp;#39;;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s">service cloud.firestore {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s"> match /databases/{database}/documents {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s"> function isSignedIn() { return request.auth != null; }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s"> function ownsExisting() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s"> return isSignedIn() &amp;amp;&amp;amp; resource.data.ownerId == request.auth.uid;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s"> function onlyChanges(fields) {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s"> return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s"> match /notes/{noteId} {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s"> allow read: if ownsExisting();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s"> allow create: if isSignedIn()
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;amp;&amp;amp; request.resource.data.ownerId == request.auth.uid;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="s"> allow update: if ownsExisting() &amp;amp;&amp;amp; onlyChanges([&amp;#39;text&amp;#39;, &amp;#39;updatedAt&amp;#39;]);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="s"> allow delete: if ownsExisting();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="s">RULES&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>onlyChanges(['text', 'updatedAt'])&lt;/code> 是這版的重點：update 只准動 &lt;code>text&lt;/code> 與 &lt;code>updatedAt&lt;/code>，碰 &lt;code>ownerId&lt;/code> 直接拒絕。下面的測試會驗證它。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a> 的 lab，實作 <a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模</a> deep article 的測試方法。前置環境見 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart</a>。測試 API 以 <a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<p>Firestore Security Rules test lab 的核心責任是把授權規則變成可自動驗證的測試。規則是 client 直連模型的整個控制面，改一條就要證明沒開新洞——這個 lab 用 <code>@firebase/rules-unit-testing</code> 在 emulator 上對規則跑斷言，產出可接進 CI 與 release gate 的測試 evidence。</p>
<p>本文的驗收標準是：你能對一組規則寫出「放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕」四類斷言、用 <code>firebase emulators:exec</code> 一鍵跑完、並看到 <code>assertFails</code> 確實證明該擋的有擋住。</p>
<h2 id="lab-環境與依賴">Lab 環境與依賴</h2>
<p>沿用 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart</a> 的工作區與 <code>firebase.json</code> / <code>firestore.rules</code>。再裝測試依賴。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp/firestore-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl">npm install --save-dev @firebase/rules-unit-testing firebase jest</span></span></code></pre></div><p>驗收前置是 <code>firestore.rules</code> 存在（quickstart 已建立 owner-scoped 規則）與 <code>firebase.json</code> 宣告了 Firestore emulator。</p>
<h2 id="升級規則加入欄位竄改防護">升級規則：加入欄位竄改防護</h2>
<p>quickstart 的規則擋了越權讀寫，但還沒擋「owner 改自己 note 時偷改 <code>ownerId</code> 把資料轉走」。先把規則升級到帶欄位白名單，讓測試有更多面向可驗。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; firestore.rules <span class="s">&lt;&lt;&#39;RULES&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">rules_version = &#39;2&#39;;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">service cloud.firestore {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">  match /databases/{database}/documents {
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    function isSignedIn() { return request.auth != null; }
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    function ownsExisting() {
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">      return isSignedIn() &amp;&amp; resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    function onlyChanges(fields) {
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">      return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">    match /notes/{noteId} {
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">      allow read: if ownsExisting();
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">      allow create: if isSignedIn()
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">                    &amp;&amp; request.resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">      allow update: if ownsExisting() &amp;&amp; onlyChanges([&#39;text&#39;, &#39;updatedAt&#39;]);
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">      allow delete: if ownsExisting();
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">RULES</span></span></span></code></pre></div><p><code>onlyChanges(['text', 'updatedAt'])</code> 是這版的重點：update 只准動 <code>text</code> 與 <code>updatedAt</code>，碰 <code>ownerId</code> 直接拒絕。下面的測試會驗證它。</p>
<h2 id="寫測試四類斷言">寫測試：四類斷言</h2>
<p>測試的核心責任是覆蓋「該放行的放行、該拒絕的拒絕」。<code>initializeTestEnvironment</code> 載入規則、<code>authenticatedContext</code> 模擬登入身分、<code>assertSucceeds</code> / <code>assertFails</code> 對操作斷言。預先種資料用 <code>withSecurityRulesDisabled</code> 繞過規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; rules.test.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const {
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">  initializeTestEnvironment, assertFails, assertSucceeds,
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">} = require(&#39;@firebase/rules-unit-testing&#39;);
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">const { doc, getDoc, setDoc, updateDoc } = require(&#39;firebase/firestore&#39;);
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">const fs = require(&#39;fs&#39;);
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">let testEnv;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">beforeAll(async () =&gt; {
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">  testEnv = await initializeTestEnvironment({
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    projectId: &#39;demo-firestore-lab&#39;,
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">    firestore: { rules: fs.readFileSync(&#39;firestore.rules&#39;, &#39;utf8&#39;) },
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">afterAll(async () =&gt; { await testEnv.cleanup(); });
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">beforeEach(async () =&gt; {
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">  await testEnv.clearFirestore();
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">  await testEnv.withSecurityRulesDisabled(async (ctx) =&gt; {
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">    await setDoc(doc(ctx.firestore(), &#39;notes/n1&#39;),
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">      { ownerId: &#39;alice&#39;, text: &#39;hi&#39;, updatedAt: 0 });
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">// 1. 放行：owner 讀自己的
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="s">test(&#39;owner reads own note&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="s">  await assertSucceeds(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="s">// 2. 越權拒絕：非 owner 讀別人的
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="s">test(&#39;non-owner cannot read&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;bob&#39;).firestore();
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="s">  await assertFails(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="s">// 3. 未登入拒絕
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="s">test(&#39;unauthenticated denied&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="s">  const db = testEnv.unauthenticatedContext().firestore();
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="s">  await assertFails(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="s">// 4. 欄位竄改拒絕：owner 偷改 ownerId
</span></span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="s">test(&#39;owner cannot change ownerId&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">46</span><span class="cl"><span class="s">  await assertFails(updateDoc(doc(db, &#39;notes/n1&#39;), { ownerId: &#39;bob&#39; }));
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="s">// 4b. 正當 update 放行
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="s">test(&#39;owner can edit text&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">52</span><span class="cl"><span class="s">  await assertSucceeds(updateDoc(doc(db, &#39;notes/n1&#39;), { text: &#39;edited&#39;, updatedAt: 1 }));
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="s">JS</span></span></span></code></pre></div><p>四類斷言裡 <code>assertFails</code> 比 <code>assertSucceeds</code> 更重要——它證明的是攻擊路徑被擋住，正是滲透測試會打的點。每條規則至少要有「正向放行 + 至少一條拒絕」配對，光測 happy path 證明不了授權安全。</p>
<h2 id="一鍵跑emulatorsexec">一鍵跑：emulators:exec</h2>
<p>跑測試的核心責任是讓它在乾淨 emulator 上自動化執行。<code>firebase emulators:exec</code> 啟動 emulator、跑指定命令、結束後關閉——適合 CI，不需要手動開關 emulator。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat &gt; package.json.test <span class="s">&lt;&lt;&#39;JSON&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">{ &#34;scripts&#34;: { &#34;test:rules&#34;: &#34;jest rules.test.js&#34; } }
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">JSON</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 把 test:rules script 併進既有 package.json 後執行：</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">firebase emulators:exec --only firestore --project demo-firestore-lab <span class="s2">&#34;npx jest rules.test.js&#34;</span></span></span></code></pre></div><p>預期輸出五個測試全 pass：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">PASS  ./rules.test.js
</span></span><span class="line"><span class="ln">2</span><span class="cl">  owner reads own note (passed)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  non-owner cannot read (passed)
</span></span><span class="line"><span class="ln">4</span><span class="cl">  unauthenticated denied (passed)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  owner cannot change ownerId (passed)
</span></span><span class="line"><span class="ln">6</span><span class="cl">  owner can edit text (passed)
</span></span><span class="line"><span class="ln">7</span><span class="cl">
</span></span><span class="line"><span class="ln">8</span><span class="cl">Test Suites: 1 passed, 1 total
</span></span><span class="line"><span class="ln">9</span><span class="cl">Tests:       5 passed, 5 total</span></span></code></pre></div><p>（Jest 預設 reporter 每行會印一個通過標記、此處以 <code>(passed)</code> 文字呈現，實際終端輸出為工具自身格式。）</p>
<h2 id="故意改壞驗證測試有效">故意改壞驗證測試有效</h2>
<p>測試的價值在於它會抓到回歸。把規則改回 <code>allow read, write: if true</code> 再跑，應看到「越權拒絕」「未登入拒絕」「欄位竄改拒絕」三個測試 fail——這證明測試確實守在攻擊路徑上，而不是恆綠的假測試。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 暫時把規則改成全放行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">printf</span> <span class="s2">&#34;rules_version=&#39;2&#39;;\nservice cloud.firestore{match /databases/{db}/documents{match /{d=**}{allow read,write:if true;}}}&#34;</span> &gt; firestore.rules
</span></span><span class="line"><span class="ln">3</span><span class="cl">firebase emulators:exec --only firestore --project demo-firestore-lab <span class="s2">&#34;npx jest rules.test.js&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 預期：3 個 assertFails 測試 fail（該擋的沒擋）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 驗證完改回上面的正確規則</span></span></span></code></pre></div><h2 id="artifact-與驗收">Artifact 與驗收</h2>
<table>
  <thead>
      <tr>
          <th>Artifact</th>
          <th>來源</th>
          <th>驗收</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>規則測試檔</td>
          <td><code>rules.test.js</code></td>
          <td>四類斷言 + 正向 update</td>
      </tr>
      <tr>
          <td>測試結果</td>
          <td><code>emulators:exec</code> 輸出</td>
          <td>正確規則下全 pass</td>
      </tr>
      <tr>
          <td>回歸證明</td>
          <td>改壞後重跑</td>
          <td>3 個 assertFails 測試轉 fail</td>
      </tr>
  </tbody>
</table>
<h2 id="接進-release-gate">接進 release gate</h2>
<p>規則測試的下游責任是成為發布證據。把 <code>firebase emulators:exec ... jest</code> 接進 CI pipeline，規則變更的 PR 必須通過才能 merge——這把「規則改動沒開新洞」從人工推敲變成 gate 條件，對齊 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a> 的 <code>Gate decision / Checks / Stop condition</code>。授權翻譯的正確性是安全邊界，這個 gate 比一般功能測試更該設為硬性 stop condition。</p>
<h2 id="cleanup">Cleanup</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># emulators:exec 跑完會自動關 emulator；清依賴與工作區</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf /tmp/firestore-lab</span></span></code></pre></div><h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a></li>
<li>Deep article：<a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模與可測試化</a></li>
<li>安全驗證：<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a></li>
<li>發布證據：<a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a></li>
<li>官方：<a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing</a>、<a href="https://firebase.google.com/docs/emulator-suite/install_and_configure">emulators:exec</a></li>
</ul>
]]></content:encoded></item></channel></rss>