<?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/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>技術教學｜林鼎淵 Dean Lin</title>
    <link>https://deanlin.net/categories/tech/</link>
    <image><url>https://deanlin.net/images/author_hu_aa256fc934e9a932.jpg</url><title>技術教學｜林鼎淵 Dean Lin</title><link>https://deanlin.net/categories/tech/</link><width>144</width><height>144</height></image>
    <description>手把手的實作教學。從挑選主題、撰寫文章到部署上線與 SEO 設定，一步步打造自己的個人品牌網站。</description>
    <generator>Hugo</generator>
    <language>zh-tw</language>
    <copyright>© 2020–2026 林鼎淵 Dean Lin</copyright>
    <lastBuildDate>Tue, 21 Jan 2025 22:07:47 +0800</lastBuildDate>
    <atom:link href="https://deanlin.net/categories/tech/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>[Hugo自架站-6]設定 Google Search Console，讓自己的網站可以被搜尋</title>
      <link>https://deanlin.net/blog/202501/hugo6-set-up-google-search-console/</link>
      <pubDate>Tue, 21 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo6-set-up-google-search-console/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>這篇文章要教大家如何設定 Google Search Console，以及上傳網站的 Sitemap（網站地圖），讓自己的網站可以被搜尋。</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/cover_hu_bc40db2c9fae3a69.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/cover_hu_b9ccfc1f7f15d36c.webp" width="1440" height="748" alt="〈設定 Google Search Console〉封面" fetchpriority="high" decoding="async"></p>
<p>上篇文章分享了如何<a href="https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/">在網站安裝 Google Analytics（GA）來分析網站的瀏覽數據</a>，而這篇文章要教大家如何設定 Google Search Console，以及上傳網站的 Sitemap（網站地圖），讓自己的網站可以被搜尋。</p>
<h3 id="驗證自己的網站是否能被-google-搜尋">驗證自己的網站是否能被 Google 搜尋<a class="h-anchor" href="#%e9%a9%97%e8%ad%89%e8%87%aa%e5%b7%b1%e7%9a%84%e7%b6%b2%e7%ab%99%e6%98%af%e5%90%a6%e8%83%bd%e8%a2%ab-google-%e6%90%9c%e5%b0%8b" aria-label="這個段落的連結"></a></h3>
<hr>
<p>在 Google Search 的搜尋框輸入 <code>site:你的網址</code>，ex: site:dean9703111.github.io</p>
<p>如果搜尋結果為「請使用 Google Search Console」，就代表你的網站尚未被 Google 收錄，別人也無法從 Google 找到你的網站。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/gooogle_search_site_hu_9df86250470a5bda.webp" width="1440" height="563" alt="Google 用 site: 指令搜尋網站，查無結果" loading="lazy" decoding="async"></p>
<h3 id="設定-google-search-console">設定 Google Search Console<a class="h-anchor" href="#%e8%a8%ad%e5%ae%9a-google-search-console" aria-label="這個段落的連結"></a></h3>
<p>點擊「請使用 Google Search Console」便會進入如下頁面：</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/gsc_hu_4ad3d00c71610ab.webp" width="1440" height="717" alt="Search Console 選擇資源類型的歡迎頁" loading="lazy" decoding="async"></p>
<p>因為目前的網域是使用 GitHub 的，所以要選擇「網址前置字元」，並貼上自己 GitHub 的網址。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/gsc2_hu_b1b758510491a18a.webp" width="1440" height="717" alt="網址前置字元欄位填入 GitHub Pages 網址" loading="lazy" decoding="async"></p>
<p>如果你有跟著<a href="https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/">上一篇文章乖乖設定 GA</a>，就會直接驗證通過。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/gsc3_hu_8efc91594d3b5dd4.webp" width="1440" height="764" alt="Search Console 已透過 GA 自動驗證擁有權" loading="lazy" decoding="async"></p>
<h3 id="上傳-sitemap-到-google-search-console">上傳 Sitemap 到 Google Search Console<a class="h-anchor" href="#%e4%b8%8a%e5%82%b3-sitemap-%e5%88%b0-google-search-console" aria-label="這個段落的連結"></a></h3>
<hr>
<p>點擊「前往資源」後，選擇左側的「Sitemap」，直接貼上 <code>sitemap.xml</code> 點擊提交就好了！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/sitemap_hu_36dc7a9ad1d431ae.webp" width="1440" height="764" alt="新增 Sitemap 欄位填入 sitemap.xml" loading="lazy" decoding="async"></p>
<blockquote>
<p>Hugo 專案的 Sitemap（網站地圖）通常是放在「public/sitemap.xml」這個位置，裡面會存放網站所有可點擊的路徑。</p>
</blockquote>
<p>剛提交的 sitemap 可能會看到如下的畫面，狀態顯示「無法擷取」。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/sitemap2_hu_f77ef373370905c7.webp" width="1440" height="764" alt="已提交的 Sitemap 狀態顯示「無法擷取」" loading="lazy" decoding="async"></p>
<p>關於這個問題，Google 官方有給了一些<a href="https://support.google.com/webmasters/answer/7451001?hl=zh-Hant">解決方案</a>，網路上也看到有遇到相同問題的人。但如果你的 Sitemap 結構沒有擅自更改，通常是 Google 的奇怪 Bug，可能等一段時間什麼事都沒做就自動恢復了。</p>
<p>但因為筆者有購買自己的網域，所以沒有等他到的狀態轉為成功；由於 SEO 是跟著網址走的，所以這邊是「強烈」建議購買一個自己的網域，相對來說比較不會遇到這些奇怪的狀況（<strong>下圖是筆者換成自己的網域，試一次就成功了</strong>）。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/sitemap3_hu_83721bd94fbc9d69.webp" width="1440" height="675" alt="換成自訂網域後 Sitemap 狀態顯示成功" loading="lazy" decoding="async"></p>
<h3 id="驗證能否在-google-搜尋到自己的網站">驗證能否在 Google 搜尋到自己的網站<a class="h-anchor" href="#%e9%a9%97%e8%ad%89%e8%83%bd%e5%90%a6%e5%9c%a8-google-%e6%90%9c%e5%b0%8b%e5%88%b0%e8%87%aa%e5%b7%b1%e7%9a%84%e7%b6%b2%e7%ab%99" aria-label="這個段落的連結"></a></h3>
<hr>
<p>在 Google Search 的搜尋框輸入 <code>site:你的網址</code>，如果搜尋得到，就代表你設定成功了！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/sitemap4_hu_d1c4f24aa51883c1.webp" width="1333" height="886" alt="Google 用 site: 指令搜尋到網站頁面" loading="lazy" decoding="async"></p>
<p>把 Sitemap 提交後，可能要等一段時間文章才會被 Google 收錄；你可以稍微等等，又或是手動把網址輸入到 Google Search Console 上，並要求他「建立索引」（檢索是有可能發生錯誤的，可以多提交幾次嘗試看看）。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo6-set-up-google-search-console/sitemap5_hu_7a5c016e6e788897.webp" width="1440" height="773" alt="Search Console 網址檢查結果與要求建立索引按鈕" loading="lazy" decoding="async"></p>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<hr>
<p>以上就是 Hugo 架站的完整教學，做到這邊就完成一個 60 分的網站了。</p>
<p>之所以會說是 60 分，是因為還有許多可以優化的地方，下面舉幾個案例：</p>
<ol>
<li><strong>網站外型優化</strong>：目前套用的是預設模板，可能有些設計跟你的期待不符，但這塊優化就需要一些網頁前端（Frontend）的基礎能力了，ChatGPT 是你成長的好夥伴。</li>
<li><strong>Open Graph</strong>：如果你把部落格文章分享給朋友，會發現連結沒有縮圖；我們需要增加 OG 標籤，才能讓網站在社群上被漂亮分享。</li>
<li><strong>canonical URL</strong>：接下來筆者打算將 Medium 文章搬到自己的部落格，為了讓 Google 了解相同內容的狀況下哪篇文章才是本尊，就需要設定這個參數，否則你部落格的流量可能會被其他網站搶走。</li>
</ol>
<p>接下來會找時間繼續分享這些細節，不想錯過的朋友可以先追蹤<a href="https://www.facebook.com/deanlinbao">筆者的 FB</a> 喔！</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>[Hugo自架站-5]安裝 Google Analytics 追蹤網頁的流量與成效</title>
      <link>https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/</link>
      <pubDate>Mon, 20 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>如果想知道訪客的行為和網站的成效，就要安裝 Google Analytics（GA）。Google Analytics 是一個功能強大的免費工具，可以讓你了解訪客的來源、行為，以及網站上的哪些內容最受歡迎。如果你想持續優化網站的 SEO，GA 就像一個指南針，告訴你正確的執行方向！</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/cover_hu_785798b6a013b812.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/cover_hu_1b915076e2b84bb7.webp" width="1440" height="750" alt="〈安裝 Google Analytics 追蹤網頁流量〉封面" fetchpriority="high" decoding="async"></p>
<p><a href="https://deanlin.net/blog/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/">將網站部署到 GitHub Pages 後</a>，如果想知道訪客的行為和網站的成效，就要安裝 Google Analytics（GA）。</p>
<p>Google Analytics 是一個功能強大的免費工具，可以讓你了解訪客的來源、行為，以及網站上的哪些內容最受歡迎。</p>
<p>如果你想持續優化網站的 SEO，GA 就像一個指南針，告訴你正確的執行方向！</p>
<h3 id="註冊-google-analytics-帳號">註冊 Google Analytics 帳號<a class="h-anchor" href="#%e8%a8%bb%e5%86%8a-google-analytics-%e5%b8%b3%e8%99%9f" aria-label="這個段落的連結"></a></h3>
<hr>
<p>可以用 Google 帳號登入 Google Analytics: <a href="https://analytics.google.com/">https://analytics.google.com/</a></p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga_hu_59e9da7d0a1fb5a4.webp" width="1440" height="678" alt="Google Analytics 建立帳戶步驟，填寫帳戶名稱" loading="lazy" decoding="async"></p>
<p>接下來會要你填寫一些資訊，下面是參考：</p>
<ul>
<li><strong>帳戶名稱</strong>: 隨意填</li>
<li><strong>屬性名稱</strong>: 可以填 GitHub 帳號</li>
</ul>
<p>另外產業類別等其他選項，依照自己實際狀況填寫即可，到最後一步的時候，我們直接選擇「網站」來做設定。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga2_hu_5937c6962d984936.webp" width="1440" height="678" alt="Google Analytics 資料收集步驟選擇網站平台" loading="lazy" decoding="async"></p>
<p>網址的部分選擇貼上先前建立的 GitHub Pages 的網址，然後給他取一個名字就好</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga3_hu_cb87b1cc4bde9eea.webp" width="1440" height="678" alt="GA 設定資料串流，填入網站網址與串流名稱" loading="lazy" decoding="async"></p>
<h3 id="取得-google-analytics-提供的追蹤碼">取得 Google Analytics 提供的追蹤碼<a class="h-anchor" href="#%e5%8f%96%e5%be%97-google-analytics-%e6%8f%90%e4%be%9b%e7%9a%84%e8%bf%bd%e8%b9%a4%e7%a2%bc" aria-label="這個段落的連結"></a></h3>
<hr>
<p>這邊我們直接點擊剛剛建立的資源。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga4_hu_72e19c21dd4e4e8e.webp" width="1440" height="678" alt="GA 資料串流列表中剛建立的網站串流" loading="lazy" decoding="async"></p>
<p>然後選擇「查看代碼操作說明」。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga5_hu_38f05e7b7b505d7f.webp" width="1440" height="678" alt="網頁串流詳情中的「查看代碼操作說明」按鈕" loading="lazy" decoding="async"></p>
<p>點擊右側的「手動安裝」，然後將下方程式碼複製下來。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga6_hu_6567368c33814815.webp" width="1440" height="678" alt="GA 手動安裝分頁中的 gtag.js 追蹤碼" loading="lazy" decoding="async"></p>
<h3 id="將-google-analytics-追蹤碼放到-hugo-模板">將 Google Analytics 追蹤碼放到 Hugo 模板<a class="h-anchor" href="#%e5%b0%87-google-analytics-%e8%bf%bd%e8%b9%a4%e7%a2%bc%e6%94%be%e5%88%b0-hugo-%e6%a8%a1%e6%9d%bf" aria-label="這個段落的連結"></a></h3>
<hr>
<p>然後把畫面切到 Hugo 專案，打開「themes/liva-hugo/layouts/partials/head.html」，滑到最下方，在 <code>&lt;/head&gt;</code> 標籤上方貼上程式。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga7_hu_ec056c0105cd378c.webp" width="1440" height="660" alt="head.html 底部貼上的 gtag.js 追蹤碼" loading="lazy" decoding="async"></p>
<p>然後將原本 google analitycs 的程式刪除。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga8_hu_142e000015064ed4.webp" width="1440" height="727" alt="head.html 中要刪除的舊版 GA 程式碼" loading="lazy" decoding="async"></p>
<p>然後打開「hugo.toml」，搜尋「Google Analitycs」，將這兩行刪掉，這樣就能移除舊版 GA 的設定了。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga9_hu_43b5f6e4eb13a30c.webp" width="1440" height="500" alt="hugo.toml 中要刪除的 GA ID 設定" loading="lazy" decoding="async"></p>
<h3 id="測試-google-analytics-是否設定成功">測試 Google Analytics 是否設定成功<a class="h-anchor" href="#%e6%b8%ac%e8%a9%a6-google-analytics-%e6%98%af%e5%90%a6%e8%a8%ad%e5%ae%9a%e6%88%90%e5%8a%9f" aria-label="這個段落的連結"></a></h3>
<hr>
<p>使用 <code>Hugo</code> 指令產生新的 public 資料夾，然後將資料夾底下的檔案拖到 GitHub Pages 的 Repository，如果對步驟不熟悉，可以<a href="https://deanlin.net/blog/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/">先回顧上一篇文章</a>。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/hugo_hu_d8b77663a7f34311.webp" width="1440" height="539" alt="終端機執行 hugo 指令後的建置結果" loading="lazy" decoding="async"></p>
<p>將版本更新推送到 GitHub 後，先前往自己部署的 GitHub Pages 的網頁刷新一下。</p>
<p>接著回到 GA 管理的頁面，點擊「首頁」，如果看到「已啟用資料收集功能」就大功告成了！你會在下方看到活躍的使用的人數！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo5-install-google-analytics-to-track-website-traffic/ga10_hu_d20d0c1efdcdd0d5.webp" width="1440" height="669" alt="GA 首頁顯示已啟用資料收集與活躍使用者人數" loading="lazy" decoding="async"></p>
<blockquote>
<p>有時 GA 的反應沒那麼快，可能會顯示「尚未收到資料」；你可以多重新整理頁面幾次，通常等個 5 分鐘就會收到資料了。</p>
</blockquote>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<hr>
<p>完成上面的設定後，你就可以透過 GA 的報表來觀察部落格網頁的流量了～</p>
<p>但現在網站還無法在 Google 被搜尋到，所以<a href="https://deanlin.net/blog/202501/hugo6-set-up-google-search-console/">下一篇文章要分享如何設定 Google Search Console</a>。</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>[Hugo自架站-4]將網站部署到 GitHub Pages</title>
      <link>https://deanlin.net/blog/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/</link>
      <pubDate>Sun, 19 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>設定好網站的基礎資訊，以及準備好第一篇部落格文章後。只要跟著教學操作，就可以將 Hugo 網站部署到 GitHub Pages 了！</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/cover_hu_469182012a599eef.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/cover_hu_413f7f44e0a4a6a5.webp" width="1440" height="750" alt="〈將網站部署到 GitHub Pages〉封面" fetchpriority="high" decoding="async"></p>
<p>設定好網站的基礎資訊，以及準備好第一篇部落格文章後，跟著教學的步驟操作，就可以將網站部署到 GitHub Pages 了！</p>
<h3 id="在-github-新增一個自己名字的-repository">在 GitHub 新增一個自己名字的 Repository<a class="h-anchor" href="#%e5%9c%a8-github-%e6%96%b0%e5%a2%9e%e4%b8%80%e5%80%8b%e8%87%aa%e5%b7%b1%e5%90%8d%e5%ad%97%e7%9a%84-repository" aria-label="這個段落的連結"></a></h3>
<hr>
<p>登入 <a href="https://github.com/">GitHub</a> 後，用 <code>自己的帳號名稱.github.io</code> 來新增一個 Repository。</p>
<p>以筆者來說，就是用 <code>dean9703111.github.io</code>，這將會是你日後個人品牌的部落格網址；另外記得要選擇「Public」才能讓大家看得到。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/new_repository_hu_3ae9ff478d06ba2.webp" width="1440" height="800" alt="GitHub 建立以帳號命名的 github.io 儲存庫" loading="lazy" decoding="async"></p>
<blockquote>
<p>小提醒：如果你帳號建立時跟筆者一樣沒想太多，導致字數超長，這在日後也是可以更新成自己的網域喔！</p>
</blockquote>
<h3 id="把-hugo-public-下的資料夾搬到剛剛建立的-repository-下">把 Hugo Public 下的資料夾搬到剛剛建立的 Repository 下<a class="h-anchor" href="#%e6%8a%8a-hugo-public-%e4%b8%8b%e7%9a%84%e8%b3%87%e6%96%99%e5%a4%be%e6%90%ac%e5%88%b0%e5%89%9b%e5%89%9b%e5%bb%ba%e7%ab%8b%e7%9a%84-repository-%e4%b8%8b" aria-label="這個段落的連結"></a></h3>
<hr>
<p>先前我們都是用 <code>hugo server</code> 來啟動網站，現在只需要執行 <code>hugo</code> 即可。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/command_hugo_hu_a8b4ba9924f45a23.webp" width="1440" height="562" alt="終端機執行 hugo 指令產生靜態網站" loading="lazy" decoding="async"></p>
<p>然後直接將生成的「public」資料夾下的內容搬到剛剛建立的 Repository 下即可。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/public_hu_bc9bad5022171b9a.webp" width="1440" height="606" alt="Finder 顯示搬進儲存庫的 public 網站檔案" loading="lazy" decoding="async"></p>
<h3 id="將變更-push-到-github-上面">將變更 Push 到 GitHub 上面<a class="h-anchor" href="#%e5%b0%87%e8%ae%8a%e6%9b%b4-push-%e5%88%b0-github-%e4%b8%8a%e9%9d%a2" aria-label="這個段落的連結"></a></h3>
<hr>
<p>接下來可以使用任何你習慣的版控工具來將變更推到 GitHub 上面，下面筆者是用 VSCode 來輸入 commit（描述調整），以及做 <code>git push</code>。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/vscode_hu_5b40213647109253.webp" width="1440" height="841" alt="VS Code 原始檔控制輸入 init 並提交變更" loading="lazy" decoding="async"></p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/git_push_hu_372a81492b5545ed.webp" width="1384" height="668" alt="終端機執行 git push 推送到 GitHub" loading="lazy" decoding="async"></p>
<h3 id="到-github-確認確認部署狀況">到 GitHub 確認確認部署狀況<a class="h-anchor" href="#%e5%88%b0-github-%e7%a2%ba%e8%aa%8d%e7%a2%ba%e8%aa%8d%e9%83%a8%e7%bd%b2%e7%8b%80%e6%b3%81" aria-label="這個段落的連結"></a></h3>
<hr>
<p>回到 GitHub 網頁，如果右下角的「Depoyments」是綠色勾勾，基本上就沒問題了！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/github_page_hu_4225f223a82471d3.webp" width="1440" height="913" alt="GitHub 儲存庫 Deployments 顯示部署成功" loading="lazy" decoding="async"></p>
<p>接者瀏覽網址（這是筆者的）: <a href="https://dean9703111.github.io">https://dean9703111.github.io</a></p>
<p>就可以順利看到自己的網站與發表的文章嚕！給自己拍拍手！現在可以將個人網站分享給其他人嚕～</p>
<p><img src="https://deanlin.net/images/post/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/web_page_hu_e526c62bb6f3166d.webp" width="1440" height="942" alt="部署完成的部落格首頁，列出 Hugo 自架站系列文章" loading="lazy" decoding="async"></p>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<hr>
<p>儘管看起來很美好，但其實還有如下細節需要設定:</p>
<ol>
<li><a href="https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/"><strong>Google Analytics</strong>: 用來追蹤網站流量成效</a></li>
<li><a href="https://deanlin.net/blog/202501/hugo6-set-up-google-search-console/"><strong>Google Search Console</strong>: 讓自己的網站在 Google 可以被搜尋到</a></li>
<li><strong>個人網域</strong>: 購買個人網域可以讓品牌看起來更專業，且轉換平台時 SEO 不會被綁架</li>
</ol>
<p>這些細節，筆者也會在<a href="https://deanlin.net/blog/202501/hugo5-install-google-analytics-to-track-website-traffic/">後續的文章</a>與大家分享。</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>[Hugo自架站-3]用 Markdown 撰寫文章，掌握分類、標籤文章的技巧</title>
      <link>https://deanlin.net/blog/202501/hugo3-write-first-post-hugo-markdown-tags/</link>
      <pubDate>Sat, 18 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo3-write-first-post-hugo-markdown-tags/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>這篇文章會先簡述常見的 Markdown 語法，讓大家了解如何在文章設定大標題、小標題、引述、圖片...等資訊。然後示範為文章加上「分類（category）、標籤（tag）」，讓我們能輕鬆管理自己部落格的文章。最後分享筆者推薦的檔案命名規則，以及文章資料夾結構。</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo3-write-first-post-hugo-markdown-tags/cover_hu_977ad5f3fdfcf051.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo3-write-first-post-hugo-markdown-tags/cover_hu_1a6c8c00e90b734b.webp" width="1440" height="796" alt="〈用 Markdown 撰寫文章，掌握分類、標籤的技巧〉封面" fetchpriority="high" decoding="async"></p>
<p><a href="https://deanlin.net/blog/202501/hugo2-setup-hugo-site-parameters/">完成 Hugo 外觀的基礎建設後</a>，接下來就可以開始撰寫部落格文章了。</p>
<p>這篇文章會先簡述常見的 Markdown 語法，讓大家了解如何在文章設定大標題、小標題、引述、圖片&hellip;等資訊。</p>
<p>然後示範為文章加上「分類（category）、標籤（tag）」，讓我們能輕鬆管理自己部落格的文章。</p>
<p>最後分享筆者推薦的檔案命名規則，以及文章資料夾結構。</p>
<h3 id="使用-markdown-語法來撰寫文章">使用 Markdown 語法來撰寫文章<a class="h-anchor" href="#%e4%bd%bf%e7%94%a8-markdown-%e8%aa%9e%e6%b3%95%e4%be%86%e6%92%b0%e5%af%ab%e6%96%87%e7%ab%a0" aria-label="這個段落的連結"></a></h3>
<hr>
<p>過去我們使用 Word，會需要使用上面的工具列來設定文章的標題、列表、粗體、斜體。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo3-write-first-post-hugo-markdown-tags/word_hu_64b418df66779e81.webp" width="1324" height="198" alt="Word 常用工具列的字型、粗體、斜體與清單按鈕" loading="lazy" decoding="async"></p>
<p>而 Markdown 則是用「符號」來設定，範例如下：</p>
<pre tabindex="0"><code>「#」開頭的就是標題，數量越少，字體越大，層級越高

# 大標題 h1

#### ▋小標題 h4

這邊就是描述的內容，你可以使用「-」的符號層級:

- 文字的格式
  - 要增加子階層，多兩個空格就可以
  - **粗體**
  - *斜體*
  - ~刪節線~

&gt; 引用的寫法
</code></pre><p>假使想顯示單行程式，可以用 1 個反引號「`」包起來。</p>
<p><code>console.log('單行程式');</code></p>
<p>如果想在某個區塊展示程式或是文字，可以將他們用 3 個連續的反引號包起來「```」</p>
<pre tabindex="0"><code>// 可以放程式語言、文字
console.log(&#39;程式碼區塊&#39;);
</code></pre><p>用 <code>[超連結](網址)</code> 的結構，就能在文字插入超連結，比如放上筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 連結。</p>
<p>用 <code>![img](/images/path)</code> 的結構，則可插入圖片。</p>
<blockquote>
<p>讀者可以到「content/blog」底下新增一個「test.md」的文章，測試不同符號對排版的影響。</p>
</blockquote>
<h3 id="掌握分類標籤文章的技巧">掌握分類、標籤文章的技巧<a class="h-anchor" href="#%e6%8e%8c%e6%8f%a1%e5%88%86%e9%a1%9e%e6%a8%99%e7%b1%a4%e6%96%87%e7%ab%a0%e7%9a%84%e6%8a%80%e5%b7%a7" aria-label="這個段落的連結"></a></h3>
<hr>
<p>從「content/blog」底下隨便點開一篇文章，你會發現最上方有些參數可以設定，下面向大家解釋每個參數代表的意義。</p>
<pre tabindex="0"><code>---
title: &#34;文章標題&#34;

# 文章撰寫的時間（新的文章會優先顯示）
date: 2025-01-16T22:07:47+08:00

# 文章是否為草稿，若設定為 true 就不會顯示在頁面上
draft: false 

# 部落格文章封面縮圖
image: &#34;images/post/post-3.jpg&#34;

# 文章的簡短介紹
description: &#34;這是分享文章連結時，會顯示的簡短描述&#34;

# 文章的分類
categories: 
  - &#34;Hugo 架站教學&#34; # 方便使用者在右側篩選（若是新增的分類，就會需要重啟 Hugo 才會生效）
tags:
  - &#34;Hugo&#34; # 這裡的標籤，會在首頁呈現（若是新增的標籤，就會需要重啟 Hugo 才會生效）
  - &#34;Web&#34;
  - &#34;自架站&#34;
  - &#34;教學&#34;

# post type
type: &#34;post&#34; # 原則上維持不變，因為目前都是要貼部落格文章
---
</code></pre><h3 id="設定-shortscodes-插入-youtube-影片ig-貼文">設定 shortscodes 插入 Youtube 影片、IG 貼文<a class="h-anchor" href="#%e8%a8%ad%e5%ae%9a-shortscodes-%e6%8f%92%e5%85%a5-youtube-%e5%bd%b1%e7%89%87ig-%e8%b2%bc%e6%96%87" aria-label="這個段落的連結"></a></h3>
<hr>
<p>如果想在自己的部落格插入其他社群媒體的元素，可以使用 shortscodes 的方案。</p>
<p>下面是<a href="https://gohugo.io/shortcodes/youtube/">插入 Youtube 影片</a>，以及<a href="https://gohugo.io/shortcodes/instagram/">插入 IG 貼文</a>的範例。</p>
<ul>
<li>Youtube 原始 URL: <code>https://www.youtube.com/watch?v=yVfafM70bzY</code></li>
</ul>
<div class="yt" data-yt="yVfafM70bzY" data-title="用 n8n 在 Google Sheet 自動建立 SEO 關鍵字矩陣">
  <a class="yt-link" href="https://www.youtube.com/watch?v=yVfafM70bzY" target="_blank" rel="noopener" aria-label="播放影片：用 n8n 在 Google Sheet 自動建立 SEO 關鍵字矩陣">
    <img src="https://i.ytimg.com/vi/yVfafM70bzY/hqdefault.jpg" alt="" width="480" height="360" loading="lazy" decoding="async">
    <span class="yt-play" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M8 5.2v13.6L19 12z"/></svg></span>
    <span class="yt-title">用 n8n 在 Google Sheet 自動建立 SEO 關鍵字矩陣</span>
  </a>
</div>

<ul>
<li>IG 原始 URL: <code>https://www.instagram.com/p/Cu9zfv0vMoR/</code></li>
</ul>
<blockquote
    class="instagram-media"
    data-instgrm-captioned
    data-instgrm-permalink="https://www.instagram.com/p/Cu9zfv0vMoR"
    data-instgrm-version="14"
    style="
      background: #fff;
      border: 0;
      border-radius: 3px;
      box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.5), 0 1px 10px 0 rgba(0, 0, 0, 0.15);
      margin: 1px;
      max-width: 540px;
      min-width: 326px;
      padding: 0;
      width: 99.375%;
      width: -webkit-calc(100% - 2px);
      width: calc(100% - 2px);
    "
  >
    <div style="padding: 16px">
      <a
        href="https://www.instagram.com/p/Cu9zfv0vMoR"
        style="
          background: #ffffff;
          line-height: 0;
          padding: 0 0;
          text-align: center;
          text-decoration: none;
          width: 100%;
        "
        target="_blank"
      >
        <div style="display: flex; flex-direction: row; align-items: center">
          <div
            style="
              background-color: #f4f4f4;
              border-radius: 50%;
              flex-grow: 0;
              height: 40px;
              margin-right: 14px;
              width: 40px;
            "
          ></div>
          <div
            style="
              display: flex;
              flex-direction: column;
              flex-grow: 1;
              justify-content: center;
            "
          >
            <div
              style="
                background-color: #f4f4f4;
                border-radius: 4px;
                flex-grow: 0;
                height: 14px;
                margin-bottom: 6px;
                width: 100px;
              "
            ></div>
            <div
              style="
                background-color: #f4f4f4;
                border-radius: 4px;
                flex-grow: 0;
                height: 14px;
                width: 60px;
              "
            ></div>
          </div>
        </div>
        <div style="padding: 19% 0"></div>
        <div
          style="display: block; height: 50px; margin: 0 auto 12px; width: 50px"
        >
          <svg
            width="50px"
            height="50px"
            viewBox="0 0 60 60"
            version="1.1"
            xmlns="https://www.w3.org/2000/svg"
            xmlns:xlink="https://www.w3.org/1999/xlink"
          >
            <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
              <g transform="translate(-511.000000, -20.000000)" fill="#000000">
                <g>
                  <path
                    d="M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631"
                  ></path>
                </g>
              </g>
            </g>
          </svg>
        </div>
        <div style="padding-top: 8px">
          <div
            style="
              color: #3897f0;
              font-family: Arial, sans-serif;
              font-size: 14px;
              font-style: normal;
              font-weight: 550;
              line-height: 18px;
            "
          >
            View this post on Instagram
          </div>
        </div>
        <div style="padding: 12.5% 0"></div>
        <div
          style="
            display: flex;
            flex-direction: row;
            margin-bottom: 14px;
            align-items: center;
          "
        >
          <div>
            <div
              style="
                background-color: #f4f4f4;
                border-radius: 50%;
                height: 12.5px;
                width: 12.5px;
                transform: translateX(0px) translateY(7px);
              "
            ></div>
            <div
              style="
                background-color: #f4f4f4;
                height: 12.5px;
                transform: rotate(-45deg) translateX(3px) translateY(1px);
                width: 12.5px;
                flex-grow: 0;
                margin-right: 14px;
                margin-left: 2px;
              "
            ></div>
            <div
              style="
                background-color: #f4f4f4;
                border-radius: 50%;
                height: 12.5px;
                width: 12.5px;
                transform: translateX(9px) translateY(-18px);
              "
            ></div>
          </div>
          <div style="margin-left: 8px">
            <div
              style="
                background-color: #f4f4f4;
                border-radius: 50%;
                flex-grow: 0;
                height: 20px;
                width: 20px;
              "
            ></div>
            <div
              style="
                width: 0;
                height: 0;
                border-top: 2px solid transparent;
                border-left: 6px solid #f4f4f4;
                border-bottom: 2px solid transparent;
                transform: translateX(16px) translateY(-4px) rotate(30deg);
              "
            ></div>
          </div>
          <div style="margin-left: auto">
            <div
              style="
                width: 0px;
                border-top: 8px solid #f4f4f4;
                border-right: 8px solid transparent;
                transform: translateY(16px);
              "
            ></div>
            <div
              style="
                background-color: #f4f4f4;
                flex-grow: 0;
                height: 12px;
                width: 16px;
                transform: translateY(-4px);
              "
            ></div>
            <div
              style="
                width: 0;
                height: 0;
                border-top: 8px solid #f4f4f4;
                border-left: 8px solid transparent;
                transform: translateY(-4px) translateX(8px);
              "
            ></div>
          </div>
        </div>
        <div
          style="
            display: flex;
            flex-direction: column;
            flex-grow: 1;
            justify-content: center;
            margin-bottom: 24px;
          "
        >
          <div
            style="
              background-color: #f4f4f4;
              border-radius: 4px;
              flex-grow: 0;
              height: 14px;
              margin-bottom: 6px;
              width: 224px;
            "
          ></div>
          <div
            style="
              background-color: #f4f4f4;
              border-radius: 4px;
              flex-grow: 0;
              height: 14px;
              width: 144px;
            "
          ></div></div
      ></a>
    </div>
  </blockquote><script async src="https://www.instagram.com/embed.js"></script>
<h3 id="用-chatgpt-生成建議的英文檔名使用年月作為資料夾結構">用 ChatGPT 生成建議的英文檔名，使用「年月」作為資料夾結構<a class="h-anchor" href="#%e7%94%a8-chatgpt-%e7%94%9f%e6%88%90%e5%bb%ba%e8%ad%b0%e7%9a%84%e8%8b%b1%e6%96%87%e6%aa%94%e5%90%8d%e4%bd%bf%e7%94%a8%e5%b9%b4%e6%9c%88%e4%bd%9c%e7%82%ba%e8%b3%87%e6%96%99%e5%a4%be%e7%b5%90%e6%a7%8b" aria-label="這個段落的連結"></a></h3>
<hr>
<p>Hugo 會使用 Markdown 文件的檔名作為網址，如果使用中文命名，那網址就會變得很長。</p>
<p>比如複製這段網址:「http://localhost:1313/blog/中文的網址」，貼上時就會因為編碼變成一長串無法辨識的字「http://localhost:1313/blog/%E4%B8%AD%E6%96%87%E7%9A%84%E7%B6%B2%E5%9D%80/」</p>
<p>如果不知道這篇文章的英文檔名如何取，可以用下面的 Prompt，請 ChatGPT 幫你一把。</p>
<pre tabindex="0"><code>幫我將下面的文章標題轉為簡短易懂的英文檔名，單字間使用「-」作為區隔:
1. 挑選合適的主題，在本地安裝並啟動 Hugo
2. 設定 Hugo 部落格網站的基礎參數（自我介紹、圖片、網站 Logo、Email、社群媒體...）
3. 在 Hugo 用 Markdown 撰寫第一篇文章，掌握分類、標籤文章的技巧
</code></pre><p><img src="https://deanlin.net/images/post/202501/hugo3-write-first-post-hugo-markdown-tags/chatGPT_hu_84c0c5f9a48e582f.webp" width="1440" height="819" alt="ChatGPT 將三篇文章標題轉成英文檔名的對話" loading="lazy" decoding="async"></p>
<p>另外 <code>content</code> 底下的文章，我建議使用「年月」的方式作為資料夾，比如「202501」。因為文章分類的部分，在「categories、tags」已經有設計了，用年月的結構，反而更容易檢視自己發文的頻率。</p>
<p>另外放置部落格圖片的 <code>assets/images</code> 也是一樣的道理，建議使用「年月/文章檔名」的結構來做存放，這樣更方便管理。</p>
<p><strong>重點：網址的結構在一開始就要決定好，因為網址就像是身分證；一但調整，過去累積的 SEO 就會歸零。</strong></p>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<hr>
<p>這邊先恭喜大家完成部落格網站的雛形，下一篇文章會帶大家<a href="https://deanlin.net/blog/202501/hugo4-deploy-hugo-github-pages-add-google-analytics/">將網站部署到 GitHub Pages</a>，完成這個步驟後，就可以把漂亮的網站分享給朋友了！</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>[Hugo自架站-2]設定部落格網站的基礎參數（自我介紹、圖片、網站 Logo、Email、社群媒體...）</title>
      <link>https://deanlin.net/blog/202501/hugo2-setup-hugo-site-parameters/</link>
      <pubDate>Fri, 17 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo2-setup-hugo-site-parameters/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>這篇文章將帶你完成 Hugo 網站的基礎設定，包括安裝 VSCode 外掛、調整網站參數、自訂 Logo 與菜單 Menu 等實用技巧，帶你一步步打造更符合個人風格的網站！</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/cover_hu_80f88b290cf62352.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/cover_hu_eb1e294171a51fc3.webp" width="1031" height="508" alt="〈設定部落格網站的基礎參數〉封面" fetchpriority="high" decoding="async"></p>
<p>如果還沒安裝好 Hugo 的朋友，可以參考上一篇文章「<a href="https://deanlin.net/blog/202501/hugo1-choose-theme-install-start-hugo/">挑選合適的主題，在本地安裝並啟動 Hugo</a>」</p>
<p>這篇文章將帶你完成 Hugo 網站的基礎設定，包括安裝 VSCode 外掛、調整網站參數、自訂 Logo 與菜單 Menu 等實用技巧，帶你一步步打造更符合個人風格的網站！</p>
<h3 id="安裝-vscode-外掛extension">安裝 VSCode 外掛（Extension）<a class="h-anchor" href="#%e5%ae%89%e8%a3%9d-vscode-%e5%a4%96%e6%8e%9bextension" aria-label="這個段落的連結"></a></h3>
<hr>
<p>Hugo 的基礎設定，大多都在附檔名為「.toml」的設定檔中調整。但預設的字體顏色都一樣，這會增加閱讀與編輯的難度。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/toml_hu_d765a7651c4c2166.webp" width="1440" height="693" alt="VS Code 中尚未語法上色的 hugo.toml" loading="lazy" decoding="async"></p>
<p>建議讀者可以先去 VSCode 的「Extension」搜尋「Toml」，然後找一個使用量最高的外掛來安裝。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/toml2_hu_7732547d496747e1.webp" width="1440" height="503" alt="VS Code 延伸模組 Even Better TOML" loading="lazy" decoding="async"></p>
<p>安裝完畢後，附檔名為「.toml」檔案的都有分色了。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/toml3_hu_72128814cd36925e.webp" width="1440" height="693" alt="安裝外掛後 hugo.toml 已有語法上色" loading="lazy" decoding="async"></p>
<h3 id="調整網頁的網址名稱">調整網頁的網址、名稱<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4%e7%b6%b2%e9%a0%81%e7%9a%84%e7%b6%b2%e5%9d%80%e5%90%8d%e7%a8%b1" aria-label="這個段落的連結"></a></h3>
<hr>
<p>打開「config/_default/hugo.toml」，將下面的參數調整成自己的：</p>
<ul>
<li><strong>baseURL</strong>: 放上自己 Github 的網址，ex: <a href="https://xxx.github.io">https://xxx.github.io</a></li>
<li><strong>languageCode</strong>: 原本是英文，我調整為中文（zh-tw）</li>
<li><strong>title</strong>: 調整為自己的網頁名稱，ex: 林鼎淵</li>
</ul>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/hugo_default_hu_64a438a2030dd478.webp" width="1440" height="440" alt="hugo.toml 中設定網站網址、語系與標題" loading="lazy" decoding="async"></p>
<p>調整完後重啟 Hugo，因為在 Local 開發，因此進入的網址會變成「http://localhost:1313」，且網頁上面名稱改變。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/hugo_default2_hu_ea80f74aceb6ee6f.webp" width="1194" height="372" alt="瀏覽器分頁標題顯示網站名稱林鼎淵" loading="lazy" decoding="async"></p>
<h3 id="將-logo作者名稱email-調整成自己的">將 Logo、作者名稱、Email 調整成自己的<a class="h-anchor" href="#%e5%b0%87-logo%e4%bd%9c%e8%80%85%e5%90%8d%e7%a8%b1email-%e8%aa%bf%e6%95%b4%e6%88%90%e8%87%aa%e5%b7%b1%e7%9a%84" aria-label="這個段落的連結"></a></h3>
<hr>
<p>打開「hugo.toml」，搜尋「default parameters」，將下面的參數調整成自己的：</p>
<ul>
<li><strong>logo</strong>: Hugo 圖片默認放在「assets」資料夾下，到「assets/images/logo.png」，將 logo.png 替換成自己的。</li>
<li><strong>author</strong>: 填上自己的名字，ex: 林鼎淵</li>
<li><strong>description</strong>: 這是網址時，網頁預覽會出現的文字描述，你可以簡述這個部落格會分享的內容</li>
<li><strong>email</strong>: 放上自己的聯絡資訊</li>
</ul>
<p>另外我這邊將「mobile、location」註解了。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/hugo_toml_hu_a737911b1bc19e95.webp" width="1440" height="624" alt="hugo.toml 中設定網站描述、作者與 Email" loading="lazy" decoding="async"></p>
<p>接著搜尋「social site」的區塊，你可以在「link」放上想要導流的社群媒體連結，而不需要的直接移除就好（我僅保留 Facebook、GitHub、Linkedin）。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/hugo_toml2_hu_cb7e4126b26791aa.webp" width="1440" height="800" alt="hugo.toml 的 social 區塊設定社群連結" loading="lazy" decoding="async"></p>
<p>調整完後，點擊「Contact」可以看到剛剛的調整。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/hugo_toml3_hu_a6561267ae5c4870.webp" width="1440" height="616" alt="Contact 頁顯示新的 Logo、社群圖示與 Email" loading="lazy" decoding="async"></p>
<h3 id="關閉-newsletter-的功能">關閉 Newsletter 的功能<a class="h-anchor" href="#%e9%97%9c%e9%96%89-newsletter-%e7%9a%84%e5%8a%9f%e8%83%bd" aria-label="這個段落的連結"></a></h3>
<hr>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/newsletter_hu_598aa06580436121.webp" width="1440" height="764" alt="Join Our Newsletter 電子報訂閱區塊" loading="lazy" decoding="async"></p>
<p>Newsletter 是提供使用者訂閱的功能，而這個功能需要與資料庫串接，而靜態網站並沒有資料庫，所以我們要關閉這個功能。</p>
<p>打開「hugo.toml」，搜尋「news letter」，把 enable 變成 false</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/newsletter2_hu_481d0e07cb78e9a0.webp" width="1440" height="291" alt="hugo.toml 中關閉 newsletter 的設定" loading="lazy" decoding="async"></p>
<p>回到剛剛的頁面，就會發現「Newsletter」的區塊移除了。</p>
<h3 id="調整語言資料夾">調整語言資料夾<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4%e8%aa%9e%e8%a8%80%e8%b3%87%e6%96%99%e5%a4%be" aria-label="這個段落的連結"></a></h3>
<hr>
<p>打開「config/_default/languages.toml」，你會發現 Hugo 預設有多國語言的設計，一開始默認文章放在「content/english」底下。</p>
<p>你可以沿用這個多國語言的設計，但筆者估計自己沒有這麼多的心力，所以把 contentDir 的「english」這層直接移除了；並將 languageCode 調整為「zh-tw」，設定如下：</p>
<pre tabindex="0"><code># --------------------------
# English language
# --------------------------
[en]
languageName = &#34;En&#34;
languageCode = &#34;zh-tw&#34;
contentDir = &#34;content&#34;
weight = 1
</code></pre><h3 id="調整自我介紹的內容about-me">調整自我介紹的內容（About Me）<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4%e8%87%aa%e6%88%91%e4%bb%8b%e7%b4%b9%e7%9a%84%e5%85%a7%e5%ae%b9about-me" aria-label="這個段落的連結"></a></h3>
<hr>
<p>因為我們上一步將「english」這層資料夾移除，所以要把下面的資料往「content」移，這樣相對路徑才能順利抓到檔案。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/content_hu_a97e7b15a996b313.webp" width="788" height="992" alt="移除 english 層後的 content 資料夾結構" loading="lazy" decoding="async"></p>
<p>接著打開「content/about/_index.md」，調整下面的設定：</p>
<ul>
<li><strong>image</strong>: 到「assets/images/author.png」，將 author.png 替換成自己的。</li>
<li><strong>description</strong>: 這是網頁預覽時會出現的文字描述。</li>
<li>把最下方的文字調整成自己的自我介紹。</li>
</ul>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/content2_hu_8f2864990d3fae89.webp" width="1440" height="837" alt="About 頁設定檔中的頭像、描述與自我介紹" loading="lazy" decoding="async"></p>
<p>調整完後，點擊「About」可以看到剛剛的調整。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/content3_hu_382daba3fbdea142.webp" width="1440" height="871" alt="About Me 頁面顯示更新後的頭像與自我介紹" loading="lazy" decoding="async"></p>
<h3 id="調整-or-隱藏自我介紹下方的-my-gallery-相簿集">調整 or 隱藏自我介紹下方的 My Gallery 相簿集<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4-or-%e9%9a%b1%e8%97%8f%e8%87%aa%e6%88%91%e4%bb%8b%e7%b4%b9%e4%b8%8b%e6%96%b9%e7%9a%84-my-gallery-%e7%9b%b8%e7%b0%bf%e9%9b%86" aria-label="這個段落的連結"></a></h3>
<hr>
<p>自我介紹下的「My Gallery」設定路徑在「data/gallery.yml」，你可以將裡面的照片調成為自己的宣傳照，或是將 enable 改為 false 來隱藏這個區塊。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/gallery_hu_356cde280989be04.webp" width="1318" height="964" alt="gallery.yml 的 enable 開關與相簿圖片清單" loading="lazy" decoding="async"></p>
<h3 id="調整移除不需要的菜單menu">調整、移除不需要的菜單（Menu）<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4%e7%a7%bb%e9%99%a4%e4%b8%8d%e9%9c%80%e8%a6%81%e7%9a%84%e8%8f%9c%e5%96%aemenu" aria-label="這個段落的連結"></a></h3>
<hr>
<p>打開「config/_default/menus.en.toml」後，會看到設定會分成 main 跟 footer 兩塊：</p>
<ul>
<li><strong>name</strong>: 網頁顯示的名稱</li>
<li><strong>URL</strong>: 網頁的路徑</li>
<li><strong>weight</strong>: 排序權重（從左到右）</li>
</ul>
<p>這邊我就將 main menu 調整如下（移除 contact、name 改為中文、weight 調整）:</p>
<pre tabindex="0"><code># main menu
[[main]]
name = &#34;關於我&#34;
URL = &#34;about/&#34;
weight = 2

[[main]]
name = &#34;文章&#34;
URL = &#34;blog/&#34;
weight = 1
</code></pre><p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/header_hu_8aa795195720d236.webp" width="1440" height="147" alt="頁首選單改為中文的文章與關於我" loading="lazy" decoding="async"></p>
<h3 id="調整網頁的-logo">調整網頁的 Logo<a class="h-anchor" href="#%e8%aa%bf%e6%95%b4%e7%b6%b2%e9%a0%81%e7%9a%84-logo" aria-label="這個段落的連結"></a></h3>
<p>如果想使用自己的 Logo，可以輸入以下指令建立對應的靜態文件資料夾：</p>
<pre tabindex="0"><code>mkdir static/images
</code></pre><p>然後在裡面放上自己的「favicon.png」就好了，<strong>Logo 僅能用「.png」的檔案喔</strong>！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo2-setup-hugo-site-parameters/favicon_hu_774dc0ed09bbab9a.webp" width="804" height="344" alt="瀏覽器分頁顯示自訂的 favicon 圖示" loading="lazy" decoding="async"></p>
<blockquote>
<p>如果發現刷新沒有反應，可能是因為 Cache 問題，<a href="https://chromewebstore.google.com/detail/clear-cache/cppjkneekbjaeellbfkmgnhonkkjfpdn?hl=zh-TW&amp;sid=h32QM2">安裝 Chrome 外掛 Clear Cache 能有效解決</a>。</p>
</blockquote>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<hr>
<p>成功將網站資訊調整為自己的後，下一步就要開始準備發表文章了。</p>
<p>Hugo 是使用 Markdown 語法來撰寫文章。下一篇文章將跟大家分享如何<a href="https://deanlin.net/blog/202501/hugo3-write-first-post-hugo-markdown-tags/">用 Markdown 撰寫文章，並掌握分類、標籤文章的技巧</a>。</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>[Hugo自架站-1]挑選合適的主題，在本地安裝並啟動 Hugo</title>
      <link>https://deanlin.net/blog/202501/hugo1-choose-theme-install-start-hugo/</link>
      <pubDate>Thu, 16 Jan 2025 22:07:47 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/hugo1-choose-theme-install-start-hugo/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>如果你跟筆者一樣選擇用靜態網站來經營個人品牌，強烈建議收藏這系列用 Hugo 架站的文章。我會從挑選主題、安裝 Hugo 開始，一步步帶你客製化網頁參數，最終將網站部署到 GitHub Pages，並加上 Google Analitycs 來追蹤流量！</description>
      <media:content url="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/cover_hu_cba5eee478372f20.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/cover_hu_5ed483b49ca44ca2.webp" width="1440" height="765" alt="〈挑選合適的主題，在本地安裝並啟動 Hugo〉封面" fetchpriority="high" decoding="async"></p>
<p>如果你跟筆者一樣選擇用靜態網站來經營個人品牌，強烈建議收藏這系列用 Hugo 架站的文章。我會從挑選主題、安裝 Hugo 開始，一步步帶你客製化網頁參數，最終將網站部署到 GitHub Pages，並加上 Google Analitycs 來追蹤流量！</p>
<h3 id="什麼是-github-pages">什麼是 GitHub Pages？<a class="h-anchor" href="#%e4%bb%80%e9%ba%bc%e6%98%af-github-pages" aria-label="這個段落的連結"></a></h3>
<hr>
<p><a href="https://github.com/">GitHub</a> 是一個程式碼版本控制的平台，而 GitHub Pages 則是它的一個副產物。</p>
<p>如果你上傳一個副檔名為「.html」的檔案，GitHub Pages 就會把它轉換成漂亮的網頁，並且這個網頁是可以透過 Google 搜尋找到的。</p>
<p>如果你是程式小白也不用擔心，因為筆者接下來會帶大家會「Hugo」這個工具，來產生個人品牌網站的 html 程式碼。</p>
<h3 id="什麼是-hugo">什麼是 Hugo？<a class="h-anchor" href="#%e4%bb%80%e9%ba%bc%e6%98%af-hugo" aria-label="這個段落的連結"></a></h3>
<hr>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/office_website_hu_7f0f9297d5b1b912.webp" width="1440" height="768" alt="Hugo 官網首頁標語：全球最快的網站建置框架" loading="lazy" decoding="async"></p>
<p>「The world’s fastest framework for building websites」</p>
<p>進入 <a href="https://gohugo.io/">Hugo 官網</a>後，你會看到這段宣傳標語，它號稱自己是<strong>全世界最快的個人網站架設工具</strong>。</p>
<h3 id="挑選自己喜歡的主題theme">挑選自己喜歡的主題（Theme）<a class="h-anchor" href="#%e6%8c%91%e9%81%b8%e8%87%aa%e5%b7%b1%e5%96%9c%e6%ad%a1%e7%9a%84%e4%b8%bb%e9%a1%8ctheme" aria-label="這個段落的連結"></a></h3>
<hr>
<p>Hugo 有提供豐富的主題供大家選擇，大家只要點擊「Themes」便可瀏覽各主題的預覽圖。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/themes_hu_97ee7bac8085bf09.webp" width="1440" height="768" alt="Hugo 官方網站的 Themes 主題列表" loading="lazy" decoding="async"></p>
<p>因為筆者的目標是建立個人品牌的部落格，所以選擇旁邊「Blog」的分類，然後就可以像是逛街般挑選自己喜歡的主題了。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/blog_hu_f79d9e87c13e3567.webp" width="1440" height="768" alt="Hugo Themes 以 Blog 標籤篩選的主題列表" loading="lazy" decoding="async"></p>
<p>看到心儀的主題後，在下載（Download）前，你可以先往下滑。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/download_hu_253c2391ae7e43dd.webp" width="1440" height="768" alt="Stack 主題的介紹頁與 Download 按鈕" loading="lazy" decoding="async"></p>
<p>部分的主題有提供 Demo，可以觀看<a href="https://demo.stack.jimmycai.com/">這個網站</a>具體呈現的效果如何。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/demo_url_hu_fdc7fd13032c7da8.webp" width="1440" height="768" alt="Stack 主題說明文件中的 Demo 網址" loading="lazy" decoding="async"></p>
<p>因為對個人品牌來說，「文章」是最大重點，所以可以多關注「封面、文字、圖片、程式碼、目錄、標題」等資訊呈現的方式。</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/demo_hu_2618f9a1068017a8.webp" width="1440" height="768" alt="Stack 主題 Demo 首頁的側欄與文章卡片" loading="lazy" decoding="async"></p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/article_hu_e1f3d3aae025ac26.webp" width="1440" height="768" alt="Stack 主題 Demo 的文章頁，含程式碼區塊與目錄" loading="lazy" decoding="async"></p>
<blockquote>
<p>因為挑來挑去沒找到合適的，所以筆者選擇了跟<a href="https://hahow.in/courses/5de8fec16117240026540b9c">古古課程</a>相同的主題（<a href="https://github.com/gethugothemes/liva-hugo">liva</a>）。</p>
</blockquote>
<h3 id="安裝-hugo從零開始建立自己的個人網站">安裝 Hugo，從零開始建立自己的個人網站<a class="h-anchor" href="#%e5%ae%89%e8%a3%9d-hugo%e5%be%9e%e9%9b%b6%e9%96%8b%e5%a7%8b%e5%bb%ba%e7%ab%8b%e8%87%aa%e5%b7%b1%e7%9a%84%e5%80%8b%e4%ba%ba%e7%b6%b2%e7%ab%99" aria-label="這個段落的連結"></a></h3>
<hr>
<p>下面的操作步驟為 <a href="https://gohugo.io/installation/macos/">Mac 環境</a>，使用 Windows 的朋友請參考<a href="https://gohugo.io/installation/windows/">官方文件的操作步驟</a></p>
<p><strong>STEP 1</strong>：打開終端機（Terminal）。</p>
<p><strong>STEP 2</strong>：安裝 <a href="https://brew.sh/">Homebrew</a></p>
<pre tabindex="0"><code>/bin/bash -c &#34;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&#34;
</code></pre><p><strong>STEP 3</strong>：用 Homebrew 安裝 Hugo</p>
<pre tabindex="0"><code>brew install hugo
</code></pre><p>安裝完成後，輸入 <code>hugo version</code> 確認自己有順利安裝</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/hugoversion_hu_d01eb7512b5723fb.webp" width="1440" height="170" alt="終端機執行 hugo version 顯示版本號" loading="lazy" decoding="async"></p>
<p><strong>STEP 4</strong>：安裝 Hugo 的 liva 主題</p>
<pre tabindex="0"><code>git clone https://github.com/gethugothemes/liva-hugo.git
</code></pre><p><strong>STEP 5</strong>：調整資料夾結構，並建立放置主題的 themes 資料夾</p>
<pre tabindex="0"><code>mv liva-hugo/exampleSite brandSite
mkdir brandSite/themes
mv liva-hugo brandSite/themes
</code></pre><h3 id="啟動-hugo">啟動 Hugo<a class="h-anchor" href="#%e5%95%9f%e5%8b%95-hugo" aria-label="這個段落的連結"></a></h3>
<hr>
<p>執行下面指令就可啟動 Hugo</p>
<pre tabindex="0"><code>cd brandSite
hugo server
</code></pre><p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/run_hugo_hu_6a0dd120367fd638.webp" width="1440" height="725" alt="終端機執行 hugo server 並顯示本機網址" loading="lazy" decoding="async"></p>
<p>進入 http://localhost:1313/liva/examplesite/ ，如果看到下面的畫面，就恭喜你成功嚕（想關閉用 Ctrl + C 即可）！</p>
<p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/hugo_init_hu_425ab1d7addc1654.webp" width="1440" height="881" alt="在本機成功啟動的 liva 主題範例網站" loading="lazy" decoding="async"></p>
<h3 id="排除-hugo-啟動時的錯誤">排除 Hugo 啟動時的錯誤<a class="h-anchor" href="#%e6%8e%92%e9%99%a4-hugo-%e5%95%9f%e5%8b%95%e6%99%82%e7%9a%84%e9%8c%af%e8%aa%a4" aria-label="這個段落的連結"></a></h3>
<hr>
<p>但如果讀者遇到這段錯誤也不要害怕：「Error: command error: failed to load modules: failed to download modules: binary with name &ldquo;go&rdquo; not found in PATH」，安裝 Go 即可。</p>
<pre tabindex="0"><code>brew install go
</code></pre><p><img src="https://deanlin.net/images/post/202501/hugo1-choose-theme-install-start-hugo/error_hu_b5e80c506a0cdc15.webp" width="1440" height="241" alt="hugo server 找不到 go 的錯誤與安裝指令" loading="lazy" decoding="async"></p>
<h3 id="結語">結語<a class="h-anchor" href="#%e7%b5%90%e8%aa%9e" aria-label="這個段落的連結"></a></h3>
<p>做到這邊就完成 Hugo 的初始化了，但現在網頁的內容都是主題預設的；筆者會在<a href="https://deanlin.net/blog/202501/hugo2-setup-hugo-site-parameters/">下一篇文章分享設定部落格網站的基礎參數（自我介紹、圖片、網站 Logo、Email、社群媒體&hellip;）的技巧</a>。</p>
<p>在文章的最後，要特別感謝<a href="https://www.facebook.com/kucw.io">古古的後端筆記</a>，許多操作步驟與設計邏輯，筆者都是參考她在 Hahow 開設的「<a href="https://hahow.in/courses/5de8fec16117240026540b9c">Github 免費架站術！輕鬆打造個人品牌</a>」這堂課，如果想知道更詳細的操作細節，歡迎到 Hahow 上支持選購。</p>
<hr>
<blockquote>
<p>我是林鼎淵，如果覺得這邊文章對你有幫助，歡迎追蹤筆者的 <a href="https://www.facebook.com/deanlinbao">Facebook</a> 與 <a href="https://www.youtube.com/@dlcorner">YouTube</a>，我會持續分享實用的新知識 💡</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>靜態網頁 VS 動態網頁：如何選擇最適合你的架站方案？</title>
      <link>https://deanlin.net/blog/202501/static-vs-dynamic-websites/</link>
      <pubDate>Fri, 03 Jan 2025 23:12:00 +0800</pubDate>
      <guid>https://deanlin.net/blog/202501/static-vs-dynamic-websites/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>你正在苦惱要選擇「靜態網頁」還是「動態網頁」嗎？ 許多人在準備架設個人網站或企業官網時，都會卡在這個問題。可能是因為預算，也可能是不確定是否需要更多的互動功能。 筆者在經歷一段時間的掙扎與研究後，最終整理出這份「評估指南」，希望能幫助你在架站這條路上，少走一些冤枉路。</description>
      <media:content url="https://deanlin.net/blog/202501/static-vs-dynamic-websites/images/static-vs-dynamic-websites-og_hu_8bf5e292ff11a3ea.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>你正在苦惱要選擇「靜態網頁」還是「動態網頁」嗎？</p>
<p>許多人在準備架設個人網站或企業官網時，都會卡在這個問題。可能是因為預算，也可能是不確定是否需要更多的互動功能。</p>
<p>筆者在經歷一段時間的掙扎與研究後，最終整理出這份「評估指南」，希望能幫助你在架站這條路上，少走一些冤枉路。</p>
<p><img src="https://deanlin.net/blog/202501/static-vs-dynamic-websites/images/static-vs-dynamic-websites-01.webp" width="1400" height="791" alt="靜態網頁 VS 動態網頁：如何選擇最適合你的架站方案？（圖 1）" fetchpriority="high" decoding="async"></p>
<h2 id="一為什麼要先決定靜態或動態">一、為什麼要先決定「靜態」或「動態」？<a class="h-anchor" href="#%e4%b8%80%e7%82%ba%e4%bb%80%e9%ba%bc%e8%a6%81%e5%85%88%e6%b1%ba%e5%ae%9a%e9%9d%9c%e6%85%8b%e6%88%96%e5%8b%95%e6%85%8b" aria-label="這個段落的連結"></a></h2>
<p>可能很多人會想：「反正我先隨便找個方案架起來，不行再換就好！」</p>
<p>但網站一但上線，多少都會累積流量和搜尋排名。若之後想大幅度更改網站架構、甚至跳到其他平台，那麼包含 SEO、資料搬家、備份…等一連串問題都會爆發，絕對會讓你崩潰。</p>
<p>所以先釐清「我需要一個什麼樣的網站」是很重要的！</p>
<hr>
<h2 id="二什麼是靜態網頁">二、什麼是「靜態網頁」？<a class="h-anchor" href="#%e4%ba%8c%e4%bb%80%e9%ba%bc%e6%98%af%e9%9d%9c%e6%85%8b%e7%b6%b2%e9%a0%81" aria-label="這個段落的連結"></a></h2>
<p>如果你在意的是「穩定呈現內容、方便維護」，並且不需要太多互動功能，那靜態網頁就會是個好選擇。</p>
<p>它的本質是以固定的 HTML、CSS、JavaScript 檔案為主，沒有後台和資料庫，所以：</p>
<ul>
<li><strong>速度快</strong>：載入時間短，也不怕因為外掛或主機效能而拖慢速度。</li>
<li><strong>維護方便</strong>：只要更新文字或圖片，就能發布新版網頁，幾乎不需要處理程式漏洞或外掛衝突。</li>
<li><strong>安全性高</strong>：因為沒有資料庫，駭客也較難利用程式或外掛漏洞入侵。</li>
</ul>
<p>如果你的目標是寫部落格，或建立個人品牌網站、作品集展示，其實靜態網頁就已經非常足夠。</p>
<p>像 Hugo、Hexo 這些靜態網站生成的工具，都能輕鬆上手，還能免費透過 GitHub Pages 部署到線上。</p>
<p>唯一的開銷，就只有「網域名稱」的費用而已。</p>
<figure class="figure"><img src="https://deanlin.net/blog/202501/static-vs-dynamic-websites/images/static-vs-dynamic-websites-02.webp" width="1400" height="883" alt="Hugo 跟 Hexo 都有豐富的主題版型供大家挑選，且可以透過版控系統（ex: GitHub）來維護，對有工程師基礎的人非常友善" loading="lazy" decoding="async">
  <figcaption>Hugo 跟 Hexo 都有豐富的主題版型供大家挑選，且可以透過版控系統（ex: GitHub）來維護，對有工程師基礎的人非常友善</figcaption>
</figure>

<hr>
<h2 id="三什麼是動態網頁">三、什麼是「動態網頁」？<a class="h-anchor" href="#%e4%b8%89%e4%bb%80%e9%ba%bc%e6%98%af%e5%8b%95%e6%85%8b%e7%b6%b2%e9%a0%81" aria-label="這個段落的連結"></a></h2>
<p>如果你的網站需要提供留言區、會員系統、線上購物或其他互動功能，那就一定需要「動態網頁」。</p>
<p>因為動態網頁背後有「後台」和「資料庫」，可以儲存使用者資料並處理各種互動，以 WordPress 來說：</p>
<ul>
<li><strong>功能擴充性強</strong>：你可以安裝多種外掛，如：購物車、SEO 工具、留言編輯器…。</li>
<li><strong>高度客製化</strong>：在視覺設計、使用者體驗上，有更多彈性。</li>
<li><strong>龐大的生態系</strong>：官方資源豐富，且社群討論十分熱絡。</li>
</ul>
<figure class="figure"><img src="https://deanlin.net/blog/202501/static-vs-dynamic-websites/images/static-vs-dynamic-websites-03.webp" width="1400" height="875" alt="WordPress 有視覺化的介面讓使用者編輯網頁外觀，並且有許多外掛可以使用。" loading="lazy" decoding="async">
  <figcaption>WordPress 有視覺化的介面讓使用者編輯網頁外觀，並且有許多外掛可以使用。</figcaption>
</figure>

<p>不過，動態網頁相對要付出更多成本及時間去管理，例如：</p>
<ul>
<li><strong>主機費用</strong>：會需要根據效能、瀏覽人數、容量等因素選擇對應的主機。</li>
<li><strong>外掛費用</strong>：部分外掛或進階功能需要付費，這塊通常只會越買越多。</li>
<li><strong>維護與資安</strong>：要定期更新系統、外掛、主題，並時常備份資料，確保不會因為駭客攻擊而全軍覆沒。</li>
</ul>
<figure class="figure"><img src="https://deanlin.net/blog/202501/static-vs-dynamic-websites/images/static-vs-dynamic-websites-04.webp" width="1400" height="875" alt="以 Bluehost 這個主機商來說，雖然 Basic 版本一個月才 2.99 美金，換算成台幣連 100 都不到；但大家要注意的是 12 個月後「Renews」的價格，竟然瞬間翻了 4 倍，變成 11.99 美金。" loading="lazy" decoding="async">
  <figcaption>以 Bluehost 這個主機商來說，雖然 Basic 版本一個月才 2.99 美金，換算成台幣連 100 都不到；但大家要注意的是 12 個月後「Renews」的價格，竟然瞬間翻了 4 倍，變成 11.99 美金。</figcaption>
</figure>

<hr>
<h2 id="四如果後來想從靜態網頁換到動態網頁seo-會崩盤嗎">四、如果後來想從靜態網頁換到動態網頁，SEO 會崩盤嗎？<a class="h-anchor" href="#%e5%9b%9b%e5%a6%82%e6%9e%9c%e5%be%8c%e4%be%86%e6%83%b3%e5%be%9e%e9%9d%9c%e6%85%8b%e7%b6%b2%e9%a0%81%e6%8f%9b%e5%88%b0%e5%8b%95%e6%85%8b%e7%b6%b2%e9%a0%81seo-%e6%9c%83%e5%b4%a9%e7%9b%a4%e5%97%8e" aria-label="這個段落的連結"></a></h2>
<p>你可能一開始靜態網頁用好好的，但後來因為知名度增加，或者開始經營電商，就會想要把靜態網站轉成動態網站。</p>
<p>在這種情況下，最常見的問題是「SEO 會不會要從頭再來？」</p>
<p>但其實只要你的網址（URL）結構沒有變動，就不會影響到過去累積的搜尋排名。</p>
<p>在搬家的過程中，也要確保每篇文章、每個頁面的網址都能對應到新的網站，盡可能避免出現 404（找不到網頁）的狀況。</p>
<hr>
<h2 id="五靜態網頁與動態網頁的成本比較">五、靜態網頁與動態網頁的成本比較<a class="h-anchor" href="#%e4%ba%94%e9%9d%9c%e6%85%8b%e7%b6%b2%e9%a0%81%e8%88%87%e5%8b%95%e6%85%8b%e7%b6%b2%e9%a0%81%e7%9a%84%e6%88%90%e6%9c%ac%e6%af%94%e8%bc%83" aria-label="這個段落的連結"></a></h2>
<h3 id="-靜態網頁">靜態網頁<a class="h-anchor" href="#-%e9%9d%9c%e6%85%8b%e7%b6%b2%e9%a0%81" aria-label="這個段落的連結"></a></h3>
<ul>
<li><strong>架設平台</strong>：如 Hugo、Hexo 都是免費使用</li>
<li><strong>部署</strong>：GitHub Pages 可以免費部署</li>
<li><strong>網域</strong>：大約每年 NT$200～1,000 不等</li>
<li><strong>SSL 憑證</strong>：有免費方案 (如: Let’s Encrypt) 可搭配使用</li>
</ul>
<h3 id="-動態網頁">動態網頁<a class="h-anchor" href="#-%e5%8b%95%e6%85%8b%e7%b6%b2%e9%a0%81" aria-label="這個段落的連結"></a></h3>
<ul>
<li><strong>主機費</strong>：依照不同主機商方案，可能 NT$100～1,000/月不等</li>
<li><strong>付費外掛 &amp; 主題</strong>：如果選擇 WordPress，像 Elementor Pro、Rank Math Pro 這類實用的外掛是需要付費的。</li>
<li><strong>資料備份 &amp; 資訊安全</strong>：如果想減少風險，建議購買知名的外掛 &amp; 第三方服務。</li>
</ul>
<p>看到這裡，你可能會想：「雖然動態網頁好處很多，但費用也高出不少，我真的有需要嗎？」</p>
<p>此時你可以先思考一下你的主要目標：是想單純寫文章、做個形象網站，還是賣課程、賣產品、與讀者互動。</p>
<p>如果你現在的預算或人力有限，也不急需商城或會員系統功能，那不如先從靜態網頁開始。等有實際需求了，再把網站轉換到動態平台也不遲。</p>
<hr>
<h2 id="六結語選擇最適合自己的方案">六、結語：選擇最適合自己的方案<a class="h-anchor" href="#%e5%85%ad%e7%b5%90%e8%aa%9e%e9%81%b8%e6%93%87%e6%9c%80%e9%81%a9%e5%90%88%e8%87%aa%e5%b7%b1%e7%9a%84%e6%96%b9%e6%a1%88" aria-label="這個段落的連結"></a></h2>
<p>方案沒有絕對的「好」或「不好」，只有「適合」與否。</p>
<ul>
<li><strong>靜態網頁</strong>：強調「穩定、輕量、低成本」，適合單純的部落格或形象網站。</li>
<li><strong>動態網頁</strong>：強調「功能多、互動性強」，適合需要會員系統、購物功能或複雜互動的商務網站。</li>
</ul>
<p>在與朋友討論、諮詢後，筆者最終的選擇是「Hugo（靜態網站生成器）」。對我來說，目前只有寫部落格、建立形象網站的需求，並不想耗費太多時間在動態網站的研究與維護上。且過去出版的書籍與課程都是跟平台合作，在靜態網頁加上導購連結就可以了。</p>
<p>但如果你想及時看到讀者回饋、經營電商，就要考慮 WordPress 這類的工具。雖然需要額外的學習和維護成本，但如果它能給你帶來更多獲利空間，那這些成本就是一種「投資」。</p>
<p>對「靜態網頁」與「動態網頁」有比較明確的認識後，最關鍵的還是在於你「真正的需求」是什麼，以及「能投入多少資源」。</p>
<p>先從目標和自身的條件出發，這樣無論你選擇哪條路，都能走得更踏實、更安心。</p>
<p>如果有描述不夠完善的地方歡迎在下方留言，讓我們一起交流、成長。</p>
<a class="linkcard" href="https://deanlin.net/series/hugo/">
  <span class="lc-body">
    <span class="lc-title">Hugo 自架站</span>
    <span class="lc-desc">用 Hugo 從零打造個人品牌網站：挑選主題、設定參數、撰寫文章、部署到 GitHub Pages，再串接 Google Analytics 與 Search Console。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>我真的需要自架站嗎？還是用部落格平台就好？從 4 個面向搞懂你的需求</title>
      <link>https://deanlin.net/blog/202412/do-i-need-a-self-hosted-site/</link>
      <pubDate>Thu, 26 Dec 2024 12:29:36 +0800</pubDate>
      <guid>https://deanlin.net/blog/202412/do-i-need-a-self-hosted-site/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>平台就像是奢華的城堡，他會幫客人準備好一切，但你不知道什麼時候會被趕出去；而自架站雖然起始於荒蕪，但你擁有絕對的話語權。</description>
      <media:content url="https://deanlin.net/blog/202412/do-i-need-a-self-hosted-site/images/do-i-need-a-self-hosted-site-og_hu_ccccc31014cbf1f5.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>不管是為了斜槓新的領域，還是作為職涯的輔助工具，越來越多人開始經營自媒體。</p>
<p>假如你沒有任何寫作經驗，並且能投入自媒體的時間不多，我強烈建議先選一個平台發表 10 篇文章後，再來思考這個問題，否則前期的評估、測試、研究、費用，會抹煞 99% 的人對寫作的熱情。</p>
<p>但如果你已經累積一定的文章量，並開始感受到平台的不便，那這篇文章非常適合你，裡面有提供具體案例和自我評估的題目，幫助你判斷是否要自架站。</p>
<p><img src="https://deanlin.net/blog/202412/do-i-need-a-self-hosted-site/images/do-i-need-a-self-hosted-site-01.webp" width="1400" height="798" alt="我真的需要自架站嗎？還是用部落格平台就好？從 4 個面向搞懂你的需求（圖 1）" fetchpriority="high" decoding="async"></p>
<hr>
<h2 id="ㄧ什麼是部落格平台什麼是自架站">ㄧ、什麼是部落格平台？什麼是自架站？<a class="h-anchor" href="#%e3%84%a7%e4%bb%80%e9%ba%bc%e6%98%af%e9%83%a8%e8%90%bd%e6%a0%bc%e5%b9%b3%e5%8f%b0%e4%bb%80%e9%ba%bc%e6%98%af%e8%87%aa%e6%9e%b6%e7%ab%99" aria-label="這個段落的連結"></a></h2>
<p>筆者過去 4 年在 Medium 平台發表超過 300 篇文章，並累積了數百萬的流量。</p>
<p>但因為前陣子<a href="https://deanlin.net/blog/202412/medium-account-suspended/">帳號被無預警停權</a>，且未來有更多客製化的業務需求，所以我開始認真研究兩者的優缺點。</p>
<h3 id="-部落格平台">部落格平台<a class="h-anchor" href="#-%e9%83%a8%e8%90%bd%e6%a0%bc%e5%b9%b3%e5%8f%b0" aria-label="這個段落的連結"></a></h3>
<p><strong>優點</strong>：</p>
<ol>
<li><strong>操作簡單</strong>：註冊帳號即可發文。</li>
<li><strong>不用維護</strong>：不用管網站的安全性、效能優化、外型設計等繁瑣的技術問題。</li>
<li><strong>更高的起點：</strong> 部分平台（ex: Medium）有內部流量，且內建較高的 SEO 權重，讓文章有更容易被看見。</li>
</ol>
<p><strong>缺點</strong>：</p>
<ol>
<li><strong>功能有限</strong>：無法自由設計網站或加入額外功能，且沒有網站主控權（ex: 無法安裝 GA / Google Search Console）。</li>
<li><strong>風險高</strong>：如果平台政策改變（ex: 收費或關閉服務），過去建立的 SEO 與內容可能會直接消失。</li>
</ol>
<h3 id="-自架站">自架站<a class="h-anchor" href="#-%e8%87%aa%e6%9e%b6%e7%ab%99" aria-label="這個段落的連結"></a></h3>
<p><strong>優點</strong>：</p>
<ol>
<li><strong>完全掌控</strong>：你可以自由設計網站外觀，並加入任何需要的功能（ex: 購物車、廣告）。</li>
<li><strong>專業形象</strong>：有自己的網址與的客製化的版面設計，能讓大家認對你有更深刻的印象。</li>
</ol>
<p><strong>缺點</strong>：</p>
<ol>
<li><strong>技術門檻高</strong>：需要花錢外包，或學習如何架設與維護網站。</li>
<li><strong>時間成本</strong>：前期的評估、建立後的維護與升級需要耗費許多精力。</li>
<li><strong>資安風險</strong>：要自行處理網站的安全性問題（ex: 購買 SSL 憑證、 防禦 DDOS 功能、程式碼漏洞…）。</li>
</ol>
<hr>
<h2 id="二透過案例來評估自己是否需要自架站">二、透過案例來評估自己是否需要自架站<a class="h-anchor" href="#%e4%ba%8c%e9%80%8f%e9%81%8e%e6%a1%88%e4%be%8b%e4%be%86%e8%a9%95%e4%bc%b0%e8%87%aa%e5%b7%b1%e6%98%af%e5%90%a6%e9%9c%80%e8%a6%81%e8%87%aa%e6%9e%b6%e7%ab%99" aria-label="這個段落的連結"></a></h2>
<ul>
<li><strong>只是想寫寫日記或分享興趣</strong>：使用部落格平台會更適合（ex: Blogger、Medium），因為你可以把注意力放在寫作，而不用擔心技術問題。</li>
<li><strong>想建立個人品牌或賺錢</strong>：自架站彈性更大，因為你可以自由設計網站，並透過廣告或聯盟行銷盈利。</li>
<li><strong>推廣商品或經營電商</strong>：自架站是必要的，你可以結合部落格推廣產品與服務，增加轉換率。</li>
</ul>
<hr>
<h2 id="三自架站前先問自己-4-個問題">三、自架站前先問自己 4 個問題<a class="h-anchor" href="#%e4%b8%89%e8%87%aa%e6%9e%b6%e7%ab%99%e5%89%8d%e5%85%88%e5%95%8f%e8%87%aa%e5%b7%b1-4-%e5%80%8b%e5%95%8f%e9%a1%8c" aria-label="這個段落的連結"></a></h2>
<p><strong>Q1: 你寫文章的目標是什麼？</strong></p>
<ul>
<li>☐ 分享興趣</li>
<li>☐ 建立品牌</li>
<li>☐ 增加收入</li>
</ul>
<p><strong>Q2: 你願意學習技術嗎？</strong></p>
<ul>
<li>☐ 願意學習架站工具（ex: WordPress、Hexo）與基本程式技能</li>
<li>☐ 希望簡單快速上手</li>
</ul>
<p><strong>Q3: 你有多少時間和預算？</strong></p>
<ul>
<li>時間（每週）：☐ &lt; 5 小時 ☐ 5–10 小時 ☐ &gt; 10 小時</li>
<li>預算（每月）：☐ 免費 ☐ 500–1000 元 ☐ &gt; 1000 元</li>
</ul>
<p><strong>Q4: 您是否能接受部落格平台政策改變帶來的風險（ex: 收費、關閉）？</strong></p>
<ul>
<li>☐ 是</li>
<li>☐ 否</li>
</ul>
<hr>
<h2 id="四深度解析自架站的優缺點">四、深度解析自架站的優缺點<a class="h-anchor" href="#%e5%9b%9b%e6%b7%b1%e5%ba%a6%e8%a7%a3%e6%9e%90%e8%87%aa%e6%9e%b6%e7%ab%99%e7%9a%84%e5%84%aa%e7%bc%ba%e9%bb%9e" aria-label="這個段落的連結"></a></h2>
<p><strong>優點：</strong></p>
<ul>
<li><strong>自由度高</strong>：能自行設計版面、擴充功能，並安裝 GA / Google Search Console 追蹤流量數據。</li>
<li><strong>專業感</strong>：專屬的網址和設計能提升信任感，在商業合作時更具有說服力。</li>
<li><strong>SEO 優勢</strong>：可自行添加 Sitemap、Canonical URL 與內部連結，讓搜尋引擎更容易爬蟲與取得排名。</li>
</ul>
<p><strong>缺點：</strong></p>
<ul>
<li><strong>需要學習</strong>：像 WordPress 雖然方便，但仍有許多外掛和設定需要掌握。</li>
<li><strong>安全性問題</strong>：需要購買 SSL 憑證、定期備份網站、安裝資安外掛。</li>
<li><strong>時間成本</strong>：你需要從「創作者」改成「經營者」的角度來看事情，要考量的細節非常多（ex: 排版、分類、視覺、廣告）</li>
</ul>
<hr>
<h2 id="五我該怎麼選">五、我該怎麼選？<a class="h-anchor" href="#%e4%ba%94%e6%88%91%e8%a9%b2%e6%80%8e%e9%ba%bc%e9%81%b8" aria-label="這個段落的連結"></a></h2>
<p>如果你：</p>
<ul>
<li><strong>只想分享興趣</strong>，不想花太多時間與金錢，部落格平台是個試水溫的好地方。</li>
<li><strong>有長期目標</strong>，希望建立個人品牌，那自架站是條繞不過去的路。</li>
</ul>
<p><img src="https://deanlin.net/blog/202412/do-i-need-a-self-hosted-site/images/do-i-need-a-self-hosted-site-02.webp" width="1400" height="644" alt="五、我該怎麼選？（圖 2）" loading="lazy" decoding="async"></p>
<p>平台就像是奢華的城堡，他會幫客人準備好一切，但你不知道什麼時候會被趕出去；而自架站雖然起始荒蕪，但你擁有絕對的話語權。</p>
<p>假如看完這篇文章後，你認為自己有自架站的需求，可以 Follow 筆者的部落格。</p>
<p><a href="https://deanlin.net/blog/202501/static-vs-dynamic-websites/">下一篇將探討選擇「自架站工具」時，有哪些細節需要注意</a>。我們真的需要 WordPress 這類有後台工具嗎？還是用 Hugo、Hexo 生成的靜態網站就已經滿足需求了？</p>
<a class="linkcard" href="https://deanlin.net/series/hugo/">
  <span class="lc-body">
    <span class="lc-title">Hugo 自架站</span>
    <span class="lc-desc">用 Hugo 從零打造個人品牌網站：挑選主題、設定參數、撰寫文章、部署到 GitHub Pages，再串接 Google Analytics 與 Search Console。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>GitLab CI 實戰（踩坑）筆記</title>
      <link>https://deanlin.net/blog/202412/gitlab-ci-notes/</link>
      <pubDate>Mon, 02 Dec 2024 18:17:56 +0800</pubDate>
      <guid>https://deanlin.net/blog/202412/gitlab-ci-notes/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>GitLab CI 是 GitLab 的內建工具，每次 Push 都會觸發一次腳本執行，內容通常涵蓋測試、編譯、部署等一系列自定義內容。 這篇文章會分享設計上要注意的細節，以及實戰過程踩過的坑。</description>
      <media:content url="https://deanlin.net/blog/202412/gitlab-ci-notes/images/gitlab-ci-notes-og_hu_93c7e0cda81c9d07.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>GitLab CI 是 GitLab 的內建工具，每次 Push 都會觸發一次腳本執行，內容通常涵蓋測試、編譯、部署等一系列自定義內容。</p>
<p>這篇文章會分享設計上要注意的細節，以及實戰過程踩過的坑，不會特別說明基礎資訊，想更深入理解的朋友可以參考<a href="https://docs.gitlab.com/ee/ci/">官網文件</a>；如果讀者有更好的方案，或對名詞有更精準的定義，歡迎留言分享。</p>
<p><img src="https://deanlin.net/blog/202412/gitlab-ci-notes/images/gitlab-ci-notes-01.webp" width="1400" height="775" alt="GitLab CI 實戰（踩坑）筆記（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>文章大綱

▋ GitLab CI 資料夾結構設計
▋ 在 .gitlab/.default.gitlab-ci.yml 定義要執行 pipeline 的 Branchs
▋ 根據專案需求定義對應的 Stages
▋ 想在不同 stage 傳遞資料時，artifacts 跟 cache 的使用時機
▋ 讓 stage 下的 job 並行執行 or 順序執行
▋ notify 階段建議要有的資訊
▋ Dockerfile 多階段構建
▋ 其他的坑（套件安裝、相容性、單元測試）
</code></pre><h2 id="-gitlab-ci-資料夾結構設計">GitLab CI 資料夾結構設計<a class="h-anchor" href="#-gitlab-ci-%e8%b3%87%e6%96%99%e5%a4%be%e7%b5%90%e6%a7%8b%e8%a8%ad%e8%a8%88" aria-label="這個段落的連結"></a></h2>
<pre tabindex="0"><code>Project
├── .gitlab-ci.yml
└── .gitlab
    ├── stages
    │   ├── 1.xxx.yml
    │   └── 2.xxx.yml
    ├── .default.gitlab-ci.yml
    └── docker-bake.hcl
</code></pre><p>下面說明一下資料夾結構下各檔案要負責的任務：</p>
<ul>
<li><strong>.gitlab-ci.yml</strong>: 定義有要執行的 stages，以及參考的 yml 檔案（ex: <code>.gitlab/.default.gitlab-ci.yml</code>）。</li>
<li><strong>.gitlab</strong>: 存放 GitLab 設定檔的資料夾。</li>
<li><strong>.gitlab/stages</strong>: 存放各 stage 的資料夾，讓邏輯更容易維護。</li>
<li><strong>.gitlab/stages/xxx.yml</strong>: 定義 stage 下各 jobs 要執行的任務。</li>
<li><strong>.gitlab/.default.gitlab-ci.yml</strong>: 用來定義默認的設定，避免重複性的工作（ex: 設定通用的 <code>variables</code>、定義要執行 pipeline 的 branchs）。</li>
<li><strong>.gitlab/docker-bake.hcl</strong>: 定義 Build Docker Image 的資訊（ex: 支援多平台、傳遞參數）。</li>
</ul>
<hr>
<h2 id="-在-gitlabdefaultgitlab-ciyml-定義要執行-pipeline-的-branchs">在 .gitlab/.default.gitlab-ci.yml 定義要執行 pipeline 的 Branchs<a class="h-anchor" href="#-%e5%9c%a8-gitlabdefaultgitlab-ciyml-%e5%ae%9a%e7%be%a9%e8%a6%81%e5%9f%b7%e8%a1%8c-pipeline-%e7%9a%84-branchs" aria-label="這個段落的連結"></a></h2>
<p>僅管我們期待每次 Push 一版都會觸發 pipeline，但有時也會有一些例外狀況（ex: 需要高頻率 Push 測試一些特殊功能）。</p>
<p>以下面的設計來說，就是僅允許「merge_request_event」以及「release/<em>、hotfix/</em>、feature/*」，還有 Default Branch（ex: master、develop）會觸發 pipeline。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">workflow</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">rules</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#7ee787">if</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;$CI_PIPELINE_SOURCE == &#34;merge_request_event&#34;&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#7ee787">if</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;$CI_COMMIT_BRANCH =~ /^release\/.*$/&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#7ee787">if</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;$CI_COMMIT_BRANCH =~ /^hotfix\/.*$/&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#7ee787">if</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;$CI_COMMIT_BRANCH =~ /^feature\/.*$/&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#7ee787">if</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH&#39;</span><span style="color:#6e7681">
</span></span></span></code></pre></div><hr>
<h2 id="-根據專案需求定義對應的-stages">根據專案需求定義對應的 Stages<a class="h-anchor" href="#-%e6%a0%b9%e6%93%9a%e5%b0%88%e6%a1%88%e9%9c%80%e6%b1%82%e5%ae%9a%e7%be%a9%e5%b0%8d%e6%87%89%e7%9a%84-stages" aria-label="這個段落的連結"></a></h2>
<p>筆者習慣設計下面 5 個 Stages:</p>
<ul>
<li><strong>init</strong>: 在這個階段做套件安裝的任務（ex: npm install）。</li>
<li><strong>analysis</strong>: 如果 init 順利才會進入這個階段，會在這裡進行單元測試(test)、Coding Style 檢查(lint)，並將對應的 report 保存到 artifacts 當中。</li>
<li><strong>build_image</strong>: 如果前面都通過才會 Build Docker Image，並將 $TARGET_IMAGE:$TAG 保存到 artifacts/tags.txt 當中（要在 notify 階段使用）。</li>
<li><strong>notify</strong>: 我將它設定為 always 執行，用來傳送 pipeline 成功/失敗的訊息到對應的 Chanel（ex: Zoom、Teams、Line）。</li>
<li><strong>deploy</strong>: 根據實際需求設計部署流程。</li>
</ul>
<hr>
<h2 id="-想在不同-stage-傳遞資料時artifacts-跟-cache-的使用時機">想在不同 stage 傳遞資料時，artifacts 跟 cache 的使用時機<a class="h-anchor" href="#-%e6%83%b3%e5%9c%a8%e4%b8%8d%e5%90%8c-stage-%e5%82%b3%e9%81%9e%e8%b3%87%e6%96%99%e6%99%82artifacts-%e8%b7%9f-cache-%e7%9a%84%e4%bd%bf%e7%94%a8%e6%99%82%e6%a9%9f" aria-label="這個段落的連結"></a></h2>
<p>上個 stage 做完的事情，在下個 stage 是不會知道細節的。</p>
<p>想要傳遞資訊就需要透過 artifacts 或 cache，而這兩者的用途不太一樣：</p>
<ul>
<li><strong>artifacts</strong>: 記錄這次 pipeline 獨有的資訊，像是 test report、image_id 這類的，建議要設定對應的 expire_in 時間，否則資料會永久保存消耗硬碟空間。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">analysis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">artifacts</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">paths</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">reports</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">expire_in</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">1</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">day</span><span style="color:#6e7681">
</span></span></span></code></pre></div><ul>
<li><strong>cache</strong>: 這個專案經常使用的資料，比如 node_modules 這類保存套件的資料夾。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">analysis</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">cache</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">key</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;${CI_PROJECT_PATH_SLUG}&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">paths</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">node_modules</span><span style="color:#6e7681">
</span></span></span></code></pre></div><hr>
<h2 id="-讓-stage-下的-job-並行執行-or-順序執行">讓 stage 下的 job 並行執行 or 順序執行<a class="h-anchor" href="#-%e8%ae%93-stage-%e4%b8%8b%e7%9a%84-job-%e4%b8%a6%e8%a1%8c%e5%9f%b7%e8%a1%8c-or-%e9%a0%86%e5%ba%8f%e5%9f%b7%e8%a1%8c" aria-label="這個段落的連結"></a></h2>
<p>如果沒有特別設計，stage 下的 job 默認都是並行執行的（比如 unit test &amp; lint 可以同時進行）；如果想要順序執行，可以透過 needs 來設定，範例如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">job1</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">stage</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">script</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#a5d6ff">echo &#34;Executing job1&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">job2</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">stage</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">script</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#a5d6ff">echo &#34;Executing job2&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">needs</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>- <span style="color:#a5d6ff">job1 </span><span style="color:#6e7681"> </span><span style="color:#8b949e;font-style:italic"># 等待 job1 完成後執行</span><span style="color:#6e7681">
</span></span></span></code></pre></div><hr>
<h2 id="-notify-階段建議要有的資訊">notify 階段建議要有的資訊<a class="h-anchor" href="#-notify-%e9%9a%8e%e6%ae%b5%e5%bb%ba%e8%ad%b0%e8%a6%81%e6%9c%89%e7%9a%84%e8%b3%87%e8%a8%8a" aria-label="這個段落的連結"></a></h2>
<p>GitLab CI 通常要跑幾分鐘的時間（通常跟 unit test 多寡有關），所以要設計 notify 的機制來傳遞資訊，建議包含的內容如下：</p>
<ul>
<li><strong>project</strong>: $PROJECT_NAME，方便了解執行的專案。</li>
<li><strong>branch</strong>: $CI_COMMIT_REF_NAME，了解哪個 branch 更新了。</li>
<li><strong>result</strong>: 會包含成功與失敗的詳細資訊。</li>
<li><strong>pipeline_link</strong>: $PIPELINE_LINK，出問題時點擊連結可以看到最直接的資訊。</li>
<li><strong>image_id</strong>: 從 artifacts 裡面讀取。</li>
</ul>
<hr>
<h2 id="-dockerfile-多階段構建">Dockerfile 多階段構建<a class="h-anchor" href="#-dockerfile-%e5%a4%9a%e9%9a%8e%e6%ae%b5%e6%a7%8b%e5%bb%ba" aria-label="這個段落的連結"></a></h2>
<p>下面用一個範例來說明如何用多階段構建（multi-stage build）來減少 Docker Image 大小並提高效率（<code>COPY &ndash;from=builder</code> 的作用是從第一階段複製文件到第二階段）。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 第一階段：使用 Node.js 安裝套件，並進行打包</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">FROM node:23.3-alpine as builder</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">WORKDIR /app</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">COPY package.json package-lock.json ./</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">RUN npm install</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">COPY . .</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">RUN npm run build</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 第二階段：使用 Nginx 部署靜態文件</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">FROM nginx:alpine3.20</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">COPY --from=builder /app/dist /usr/share/nginx/html</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">WORKDIR /usr/share/nginx/html</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">COPY ./build/nginx/default.conf /etc/nginx/conf.d/default.conf</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>透過這個方案，最終的 Image 只有靜態文件跟 Nginx，不包含 Node.js 開發工具；不但減少 Image 大小，還減少了潛在攻擊面向。</p>
<hr>
<h2 id="-其他的坑套件安裝相容性單元測試">其他的坑（套件安裝、相容性、單元測試）<a class="h-anchor" href="#-%e5%85%b6%e4%bb%96%e7%9a%84%e5%9d%91%e5%a5%97%e4%bb%b6%e5%ae%89%e8%a3%9d%e7%9b%b8%e5%ae%b9%e6%80%a7%e5%96%ae%e5%85%83%e6%b8%ac%e8%a9%a6" aria-label="這個段落的連結"></a></h2>
<ul>
<li><strong>套件無法順利安裝</strong>: 如果套件是經由私有庫來安裝的，就需要設定對應的網路環境；如果在 Dockerfile 裡面安裝，請記得將 <code>.npmrc</code> 這類設定 COPY 到對應的位置。</li>
<li><strong>qemu-user-static 相容性錯誤</strong>: 有些專案需要指定對應的 platform 才能順利 build 出來（Dockerfile from 的 image 可能僅支持 arm 或 amd）。</li>
<li><strong>單元測試錯誤突然暴增</strong>: 如果在 local 環境測試起來正常，但在 GitLab CI 時出現錯誤，那通常是「非同步」的問題，可以先用 <code>await</code> 拉長等待時間，並搭配 log 來釐清狀況。</li>
</ul>
<p>有段時間沒發純技術的文章了，希望能幫助到有相關需求的朋友們，也歡迎大家分享自己在 GitLab CI 的使用經驗。</p>
]]></content:encoded>
    </item>
    <item>
      <title>用 tree 套件生成專案樹狀的資料夾（目錄）結構</title>
      <link>https://deanlin.net/blog/202407/project-tree-structure/</link>
      <pubDate>Thu, 11 Jul 2024 18:11:07 +0800</pubDate>
      <guid>https://deanlin.net/blog/202407/project-tree-structure/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>隨著專案迭代，除了程式碼越來越龐大外，資料夾結構也越來越複雜。這邊推薦一個直接產生資料夾結構的套件「tree」，透過簡單的指令就能生成樹狀的目錄結構。</description>
      <media:content url="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-og_hu_895f5209e82220a2.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<h2 id="用-tree-套件生成專案樹狀資料夾目錄結構">用 tree 套件生成專案樹狀資料夾（目錄）結構<a class="h-anchor" href="#%e7%94%a8-tree-%e5%a5%97%e4%bb%b6%e7%94%9f%e6%88%90%e5%b0%88%e6%a1%88%e6%a8%b9%e7%8b%80%e8%b3%87%e6%96%99%e5%a4%be%e7%9b%ae%e9%8c%84%e7%b5%90%e6%a7%8b" aria-label="這個段落的連結"></a></h2>
<p>隨著專案迭代，除了程式碼越來越龐大外，資料夾結構也越來越複雜。</p>
<p>到後來，可能連開發人員自己都搞不清楚每個資料夾在做什麼事。</p>
<p>為了避免交接、教育訓練時要花很多時間回憶、解釋，我們應該把這些資訊記錄到文件中。</p>
<p>這邊推薦一個直接產生資料夾結構的套件「<a href="https://www.npmjs.com/package/tree-cli">tree</a>」，透過簡單的指令就能生成樹狀的目錄結構，以此加速文件撰寫。</p>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-01.webp" width="1400" height="786" alt="用 tree 套件生成專案樹狀資料夾（目錄）結構（圖 1）" fetchpriority="high" decoding="async"></p>
<h2 id="-安裝-tree-套件">安裝 tree 套件<a class="h-anchor" href="#-%e5%ae%89%e8%a3%9d-tree-%e5%a5%97%e4%bb%b6" aria-label="這個段落的連結"></a></h2>
<p>在 Windows &amp; Mac 有安裝 npm 的電腦上，只要輸入如下指令便可安裝。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>npm install -g tree-cli
</span></span></code></pre></div><p>安裝好後，可透過 <code>tree -h</code> 查看有哪些指令可以使用。</p>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-02.webp" width="659" height="652" alt="安裝 tree 套件（圖 2）" loading="lazy" decoding="async"></p>
<p>官方文件中有提醒，Windows 要用「treee」來當指令（多一個 e），筆者的環境為 Mac。</p>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-03.webp" width="631" height="185" alt="安裝 tree 套件（圖 3）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="-使用-tree-生成樹狀的目錄結構">使用 tree 生成樹狀的目錄結構<a class="h-anchor" href="#-%e4%bd%bf%e7%94%a8-tree-%e7%94%9f%e6%88%90%e6%a8%b9%e7%8b%80%e7%9a%84%e7%9b%ae%e9%8c%84%e7%b5%90%e6%a7%8b" aria-label="這個段落的連結"></a></h2>
<p>下面分享幾個高頻率使用的指令：</p>
<ul>
<li><code>tree</code> : 顯示「資料夾」與「目錄」</li>
</ul>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-04.webp" width="440" height="446" alt="使用 tree 生成樹狀的目錄結構（圖 4）" loading="lazy" decoding="async"></p>
<ul>
<li><code>tree -d</code> : 僅顯示「資料夾」</li>
</ul>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-05.webp" width="399" height="424" alt="使用 tree 生成樹狀的目錄結構（圖 5）" loading="lazy" decoding="async"></p>
<ul>
<li><code>tree -l n</code> : 顯示到第 n 層目錄（tree -l 2 就是顯示兩層目錄）</li>
</ul>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-06.webp" width="399" height="709" alt="使用 tree 生成樹狀的目錄結構（圖 6）" loading="lazy" decoding="async"></p>
<ul>
<li><code>tree &ndash;ignore pattern</code> : 排除不要顯示的檔案/資料夾（tree &ndash;ignore node_modules 就能排除 node_modules 資料夾）</li>
</ul>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-07.webp" width="448" height="396" alt="使用 tree 生成樹狀的目錄結構（圖 7）" loading="lazy" decoding="async"></p>
<ul>
<li><code>tree -o filename</code> : 將結果寫入 filename 這個檔案</li>
</ul>
<p>這邊展示一個組合指令： <code>tree &ndash;ignore node_modules -l 3 -d &gt; README.md</code></p>
<p>這個指令能將 3 層目錄資料夾（排除 node_modules）結果寫入 README.md 這個檔案中。</p>
<p>一開始生成好後，README.md 的預覽會因為沒有排版導致難以閱讀。</p>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-08.webp" width="1400" height="680" alt="使用 tree 生成樹狀的目錄結構（圖 8）" loading="lazy" decoding="async"></p>
<p>但只要在開頭和結尾加上「```」就會排版嚕！</p>
<p><img src="https://deanlin.net/blog/202407/project-tree-structure/images/project-tree-structure-09.webp" width="1400" height="606" alt="使用 tree 生成樹狀的目錄結構（圖 9）" loading="lazy" decoding="async"></p>
<p>以上就是這篇文章的分享，希望這個套件能幫助大家更輕鬆、清晰的撰寫與表達文件。</p>
]]></content:encoded>
    </item>
    <item>
      <title>MongoDB 與 MySQL 建立 Index 對 Read/Write 的效能影響</title>
      <link>https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/</link>
      <pubDate>Sat, 25 May 2024 17:55:38 +0800</pubDate>
      <guid>https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>儘管網路上有許多資料顯示 MongoDB 建立 Index 後的效能比 MySQL 好，但這篇文章我想要透過程式來驗證兩者的寫入、讀取、資料大小差異。</description>
      <media:content url="https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/images/mongodb-vs-mysql-index-performance-og_hu_36b1bfdad50794f5.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><a href="https://deanlin.net/blog/202405/database-bottlenecks/">上篇文章</a>分享了朋友遇到資料庫瓶頸的案例，收到許多網友的熱情回饋。</p>
<p>其中有個留言很有意思：「如果在 MongoDB 建立 Index，那麼資料寫入的速度會比 MySQL 快很多嗎（有建立相同 Index 的情況下）？」</p>
<p>儘管網路上有許多資料顯示 MongoDB 建立 Index 後的效能比 MySQL 好，但這篇文章我想要透過程式來驗證兩者寫入、讀取、資料大小的差異。</p>
<p><img src="https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/images/mongodb-vs-mysql-index-performance-01.webp" width="1400" height="787" alt="MongoDB 與 MySQL 建立 Index 對 Read/Write 的效能影響（圖 1）" fetchpriority="high" decoding="async"></p>
<blockquote>
<p>非常建議看到最後，因為測試結果可能跟你想的不一樣。</p>
</blockquote>
<pre tabindex="0"><code>文章大綱

▋ 測試參數說明
▋ 前置作業 1 ── docker image pull
▋ 前置作業 2 ── 安裝 Python 相關套件
▋ Script 設計說明
▋ MySQL 測試方案
▋ MongoDB 測試方案 1 ── 單筆資料的 Index 設計
▋ MongoDB 測試方案 2 ── Time Series Collection
▋ MongoDB 測試方案 3 ── Embedded Documents
▋ 結論：想象跟實際的落差
</code></pre><h2 id="-測試參數說明">測試參數說明<a class="h-anchor" href="#-%e6%b8%ac%e8%a9%a6%e5%8f%83%e6%95%b8%e8%aa%aa%e6%98%8e" aria-label="這個段落的連結"></a></h2>
<p>這邊我以<a href="https://deanlin.net/blog/202405/database-bottlenecks/">上一篇</a>談到的商品統計圖表來做設計，因為主要比對效能上的差異，所以用的資料量級比較小（否則某些情境會跑到天荒地老）。</p>
<ul>
<li>商品種類: 2000 筆</li>
<li>統計頻率: 1 分鐘</li>
<li>時間長度: 8 小時</li>
<li>總資料量: 96 萬筆（2000 * 8 * 60）</li>
<li>電腦規格: Apple M1 Max</li>
</ul>
<hr>
<h2 id="-前置作業-1--docker-image-pull">前置作業 1 ── docker image pull<a class="h-anchor" href="#-%e5%89%8d%e7%bd%ae%e4%bd%9c%e6%a5%ad-1--docker-image-pull" aria-label="這個段落的連結"></a></h2>
<p>如果你有興趣，可以跟著文章步驟一起實作，為求公平，我這邊使用最新版的 MongoDB 跟 MySQL 來做測試（當然也可以直接滑到最後看實驗結果）。</p>
<p>我也有把文章的 Code 推到 <a href="https://github.com/dean9703111/mongodb-mysql-index-performance">Github</a> 讓大家參考。</p>
<p>如果有使用 Docker，可以使用下面的 <code>docker-compose.yaml</code> 取的對應的資源。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;1.1&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mongodb</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mongodb/mongodb-community-server:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mongodb</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;27017:27017&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mongodb_data:/data/db</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mynetwork</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mongo-express</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mongo-express:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mongo-express</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8081:8081&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">ME_CONFIG_MONGODB_SERVER</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mongodb</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">ME_CONFIG_BASICAUTH_USERNAME</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">root</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">ME_CONFIG_BASICAUTH_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">example</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mynetwork</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mysql</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mysql/mysql-server:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mysql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;3306:3306&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">MYSQL_ROOT_PASSWORD</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">example</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">MYSQL_DATABASE</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">exampledb</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mysql_data:/var/lib/mysql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./mysql-conf.d:/etc/mysql/conf.d</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mynetwork</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">phpmyadmin</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">phpmyadmin/phpmyadmin:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">phpmyadmin</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">depends_on</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mysql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;8080:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">PMA_HOST</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">mysql</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span><span style="color:#7ee787">PMA_PORT</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">3306</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">mynetwork</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mongodb_data</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mysql_data</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">networks</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">mynetwork</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">driver</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">bridge</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>下面是對應的 GUI 連結:</p>
<ul>
<li><strong>phpMyAdmin</strong>（帳號:root、密碼:example）: <a href="http://localhost:8080">http://localhost:8080</a></li>
<li><strong>Mongo Express</strong>（帳號:root、密碼:example）: <a href="http://localhost:8081">http://localhost:8081</a></li>
</ul>
<hr>
<h2 id="-前置作業-2--安裝-python-相關套件">前置作業 2 ── 安裝 Python 相關套件<a class="h-anchor" href="#-%e5%89%8d%e7%bd%ae%e4%bd%9c%e6%a5%ad-2--%e5%ae%89%e8%a3%9d-python-%e7%9b%b8%e9%97%9c%e5%a5%97%e4%bb%b6" aria-label="這個段落的連結"></a></h2>
<p>我使用的 Python 版本為: 3.10.14</p>
<p>這邊我使用 Python 來撰寫測試用的 Script，若你尚未安裝，可以直接去官網下載: <a href="https://www.python.org/downloads/">https://www.python.org/downloads/</a></p>
<p>執行下面指令便可安裝專安會用到的套件：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>pip install pymysql pymongo cryptography
</span></span></code></pre></div><hr>
<h2 id="-script-設計說明">Script 設計說明<a class="h-anchor" href="#-script-%e8%a8%ad%e8%a8%88%e8%aa%aa%e6%98%8e" aria-label="這個段落的連結"></a></h2>
<p>我總共寫了 4 個測試腳本，MySQL 1 個，MongoDB 3 個，基本上每個腳本都會做如下任務，差別在於資料結構設計不同。</p>
<ol>
<li>與資料庫（MySQL/MongoDB）建立連線</li>
<li>建立 Table/Collection，並在「商品名稱(product_name)、紀錄時間(timestamp)」的欄位加上 index</li>
<li>寫入資料，模擬為期 8 小時、1 分鐘寫入 2000 筆資料的情境。</li>
<li>查詢效能測試，會亂數產生 100 次不同參數的搜尋條件。</li>
<li>印出寫入時間/寫入總量/平均查詢時間</li>
</ol>
<hr>
<h2 id="-mysql-測試方案">MySQL 測試方案<a class="h-anchor" href="#-mysql-%e6%b8%ac%e8%a9%a6%e6%96%b9%e6%a1%88" aria-label="這個段落的連結"></a></h2>
<p>MySQL 版本: 8.0.32</p>
<p>這個腳本沒什麼特別的，只是在「商品名稱(product_name)、紀錄時間(timestamp)」加上了 index。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">pymysql</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">random</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">time</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">datetime</span> <span style="color:#ff7b72">import</span> datetime, timedelta
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># MySQL 連接設定</span>
</span></span><span style="display:flex;"><span>conn <span style="color:#ff7b72;font-weight:bold">=</span> pymysql<span style="color:#ff7b72;font-weight:bold">.</span>connect(
</span></span><span style="display:flex;"><span>    host<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#39;localhost&#39;</span>,
</span></span><span style="display:flex;"><span>    user<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#39;root&#39;</span>,
</span></span><span style="display:flex;"><span>    password<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#39;example&#39;</span>,
</span></span><span style="display:flex;"><span>    database<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#39;exampledb&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>cursor <span style="color:#ff7b72;font-weight:bold">=</span> conn<span style="color:#ff7b72;font-weight:bold">.</span>cursor()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 建立 DB 和 Table</span>
</span></span><span style="display:flex;"><span>sql_commands <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#34;CREATE DATABASE IF NOT EXISTS exampledb;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#34;USE exampledb;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">    CREATE TABLE IF NOT EXISTS product_stats (
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">        id BIGINT AUTO_INCREMENT PRIMARY KEY,
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">        product_name VARCHAR(255),
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">        timestamp DATETIME,
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">        data TEXT,
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">        INDEX idx_product_name_timestamp (product_name, timestamp)
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">    );
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">    &#34;&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#34;TRUNCATE product_stats;&#34;</span>
</span></span><span style="display:flex;"><span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 執行 SQL 指令</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> command <span style="color:#ff7b72;font-weight:bold">in</span> sql_commands:
</span></span><span style="display:flex;"><span>    cursor<span style="color:#ff7b72;font-weight:bold">.</span>execute(command)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>conn<span style="color:#ff7b72;font-weight:bold">.</span>commit()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入資料</span>
</span></span><span style="display:flex;"><span>start_time <span style="color:#ff7b72;font-weight:bold">=</span> datetime<span style="color:#ff7b72;font-weight:bold">.</span>now()
</span></span><span style="display:flex;"><span>insert_batch_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span>  <span style="color:#8b949e;font-style:italic"># 每批次寫入數據量</span>
</span></span><span style="display:flex;"><span>product_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span> <span style="color:#8b949e;font-style:italic"># 要測試的商品總量</span>
</span></span><span style="display:flex;"><span>total_records <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>  <span style="color:#8b949e;font-style:italic"># 紀錄寫入的數據總量</span>
</span></span><span style="display:flex;"><span>time_range <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">8</span> <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">60</span> <span style="color:#8b949e;font-style:italic"># 總時間 8 hours</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 開始計時</span>
</span></span><span style="display:flex;"><span>insert_start_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 建立數據並批量寫入</span>
</span></span><span style="display:flex;"><span>batch <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> minute <span style="color:#ff7b72;font-weight:bold">in</span> range(time_range):
</span></span><span style="display:flex;"><span>    timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>minute)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> i <span style="color:#ff7b72;font-weight:bold">in</span> range(product_size):  <span style="color:#8b949e;font-style:italic"># 每分鐘要寫入的商品紀錄</span>
</span></span><span style="display:flex;"><span>        product_name <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>  <span style="color:#8b949e;font-style:italic"># 模擬 product_size 種不同商品</span>
</span></span><span style="display:flex;"><span>        stats <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;統計資料</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>
</span></span><span style="display:flex;"><span>        batch<span style="color:#ff7b72;font-weight:bold">.</span>append((product_name, timestamp, stats))
</span></span><span style="display:flex;"><span>        total_records <span style="color:#ff7b72;font-weight:bold">+=</span> <span style="color:#a5d6ff">1</span>
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#8b949e;font-style:italic"># 批量寫入</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">if</span> len(batch) <span style="color:#ff7b72;font-weight:bold">&gt;=</span> insert_batch_size:
</span></span><span style="display:flex;"><span>            cursor<span style="color:#ff7b72;font-weight:bold">.</span>executemany(
</span></span><span style="display:flex;"><span>                <span style="color:#a5d6ff">&#34;INSERT INTO product_stats (product_name, timestamp, data) VALUES (</span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">)&#34;</span>,
</span></span><span style="display:flex;"><span>                batch
</span></span><span style="display:flex;"><span>            )
</span></span><span style="display:flex;"><span>            conn<span style="color:#ff7b72;font-weight:bold">.</span>commit()
</span></span><span style="display:flex;"><span>            batch <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入剩餘的數據</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> batch:
</span></span><span style="display:flex;"><span>    cursor<span style="color:#ff7b72;font-weight:bold">.</span>executemany(
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;INSERT INTO product_stats (product_name, timestamp, data) VALUES (</span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">, </span><span style="color:#a5d6ff">%s</span><span style="color:#a5d6ff">)&#34;</span>,
</span></span><span style="display:flex;"><span>        batch
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    conn<span style="color:#ff7b72;font-weight:bold">.</span>commit()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計時結束</span>
</span></span><span style="display:flex;"><span>insert_end_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 查詢效能測試</span>
</span></span><span style="display:flex;"><span>query_times <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">100</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 隨機生成查詢的時間區間</span>
</span></span><span style="display:flex;"><span>    start_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">0</span>, time_range<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">30</span>))  <span style="color:#8b949e;font-style:italic"># 最大值為 - 30 為 30 分鐘</span>
</span></span><span style="display:flex;"><span>    end_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_timestamp <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">30</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 查詢多筆商品名稱在特定時間範圍內的資料</span>
</span></span><span style="display:flex;"><span>    product_names <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">1</span>, product_size)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span> <span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">10</span>)]  <span style="color:#8b949e;font-style:italic"># 隨機查詢 10 個商品</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 使用 IN 進行多商品名稱查詢</span>
</span></span><span style="display:flex;"><span>    format_strings <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#39;,&#39;</span><span style="color:#ff7b72;font-weight:bold">.</span>join([<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;&#39;</span><span style="color:#a5d6ff">{</span>name<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39;&#34;</span> <span style="color:#ff7b72">for</span> name <span style="color:#ff7b72;font-weight:bold">in</span> product_names])
</span></span><span style="display:flex;"><span>    query <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;SELECT * FROM product_stats WHERE product_name IN (</span><span style="color:#a5d6ff">{</span>format_strings<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">) AND timestamp BETWEEN &#39;</span><span style="color:#a5d6ff">{</span>start_timestamp<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39; AND &#39;</span><span style="color:#a5d6ff">{</span>end_timestamp<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#39;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 執行查詢</span>
</span></span><span style="display:flex;"><span>    start_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    cursor<span style="color:#ff7b72;font-weight:bold">.</span>execute(query)
</span></span><span style="display:flex;"><span>    rows <span style="color:#ff7b72;font-weight:bold">=</span> cursor<span style="color:#ff7b72;font-weight:bold">.</span>fetchall()
</span></span><span style="display:flex;"><span>    end_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    query_times<span style="color:#ff7b72;font-weight:bold">.</span>append(end_query <span style="color:#ff7b72;font-weight:bold">-</span> start_query)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MySQL 寫入時間: </span><span style="color:#a5d6ff">{</span>insert_end_time <span style="color:#ff7b72;font-weight:bold">-</span> insert_start_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MySQL 寫入總量: </span><span style="color:#a5d6ff">{</span>total_records<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 條&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計算平均查詢時間</span>
</span></span><span style="display:flex;"><span>average_query_time <span style="color:#ff7b72;font-weight:bold">=</span> sum(query_times) <span style="color:#ff7b72;font-weight:bold">/</span> len(query_times)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MySQL 平均查詢時間: </span><span style="color:#a5d6ff">{</span>average_query_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>cursor<span style="color:#ff7b72;font-weight:bold">.</span>close()
</span></span><span style="display:flex;"><span>conn<span style="color:#ff7b72;font-weight:bold">.</span>close()
</span></span></code></pre></div><p>下面為 MySQL 測試結果:</p>
<ul>
<li><strong>寫入時間</strong>: 11.6187 秒</li>
<li><strong>平均查詢時間</strong>: 0.0058 秒</li>
<li><strong>資料大小</strong>: 151.4 MB</li>
</ul>
<hr>
<h2 id="-mongodb-測試方案-1--單筆資料的-index-設計">MongoDB 測試方案 1 ── 單筆資料的 Index 設計<a class="h-anchor" href="#-mongodb-%e6%b8%ac%e8%a9%a6%e6%96%b9%e6%a1%88-1--%e5%96%ae%e7%ad%86%e8%b3%87%e6%96%99%e7%9a%84-index-%e8%a8%ad%e8%a8%88" aria-label="這個段落的連結"></a></h2>
<p>MongoDB 版本: 7.0.9</p>
<p>這個腳本是可以直接與 MySQL 比對的版本，因為都是採 row data 的概念，僅在「商品名稱(product_name)、紀錄時間(timestamp)」加上了 index。數據格式如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;product_name&#34;</span> : <span style="color:#a5d6ff">&#34;商品名稱2&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;timestamp&#34;</span> : <span style="color:#f85149">ISODate(</span><span style="color:#a5d6ff">&#34;2024-05-25T15:24:46.492+0000&#34;</span><span style="color:#f85149">)</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;data&#34;</span> : <span style="color:#a5d6ff">&#34;統計資料2&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">pymongo</span> <span style="color:#ff7b72">import</span> MongoClient, ASCENDING
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">random</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">time</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">datetime</span> <span style="color:#ff7b72">import</span> datetime, timedelta
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># MongoDB 連接設置</span>
</span></span><span style="display:flex;"><span>client <span style="color:#ff7b72;font-weight:bold">=</span> MongoClient(<span style="color:#a5d6ff">&#39;mongodb://localhost:27017/&#39;</span>)
</span></span><span style="display:flex;"><span>db <span style="color:#ff7b72;font-weight:bold">=</span> client<span style="color:#ff7b72;font-weight:bold">.</span>testdb
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 清空集合並創建新集合</span>
</span></span><span style="display:flex;"><span>db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>drop()
</span></span><span style="display:flex;"><span>db<span style="color:#ff7b72;font-weight:bold">.</span>create_collection(<span style="color:#a5d6ff">&#39;product_stats&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 加入 index</span>
</span></span><span style="display:flex;"><span>db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>create_index([(<span style="color:#a5d6ff">&#34;product_name&#34;</span>, ASCENDING), (<span style="color:#a5d6ff">&#34;timestamp&#34;</span>, ASCENDING)])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入數據</span>
</span></span><span style="display:flex;"><span>start_time <span style="color:#ff7b72;font-weight:bold">=</span> datetime<span style="color:#ff7b72;font-weight:bold">.</span>now()
</span></span><span style="display:flex;"><span>insert_batch_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span>  <span style="color:#8b949e;font-style:italic"># 每批次寫入數據量</span>
</span></span><span style="display:flex;"><span>product_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span> <span style="color:#8b949e;font-style:italic"># 要測試的商品總量</span>
</span></span><span style="display:flex;"><span>total_records <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>  <span style="color:#8b949e;font-style:italic"># 記錄寫入的數據總量</span>
</span></span><span style="display:flex;"><span>time_range <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">8</span> <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">60</span> <span style="color:#8b949e;font-style:italic"># 總時間 8 hours</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 開始計時</span>
</span></span><span style="display:flex;"><span>insert_start_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 建立數據並批量寫入</span>
</span></span><span style="display:flex;"><span>batch <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> minute <span style="color:#ff7b72;font-weight:bold">in</span> range(time_range):  <span style="color:#8b949e;font-style:italic"># 8 小時</span>
</span></span><span style="display:flex;"><span>    timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>minute)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> i <span style="color:#ff7b72;font-weight:bold">in</span> range(product_size):  <span style="color:#8b949e;font-style:italic"># 每分鐘要寫入的商品紀錄</span>
</span></span><span style="display:flex;"><span>        product_name <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>  <span style="color:#8b949e;font-style:italic"># 模擬 product_size 種不同商品</span>
</span></span><span style="display:flex;"><span>        stats <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;統計資料</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>
</span></span><span style="display:flex;"><span>        batch<span style="color:#ff7b72;font-weight:bold">.</span>append({
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;product_name&#34;</span>: product_name,
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;timestamp&#34;</span>: timestamp,
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;data&#34;</span>: stats
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>        total_records <span style="color:#ff7b72;font-weight:bold">+=</span> <span style="color:#a5d6ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#8b949e;font-style:italic"># 批量寫入</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">if</span> len(batch) <span style="color:#ff7b72;font-weight:bold">&gt;=</span> insert_batch_size:
</span></span><span style="display:flex;"><span>            db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>insert_many(batch)
</span></span><span style="display:flex;"><span>            batch<span style="color:#ff7b72;font-weight:bold">.</span>clear()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入剩餘的數據</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> batch:
</span></span><span style="display:flex;"><span>    db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>insert_many(batch)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計時結束</span>
</span></span><span style="display:flex;"><span>insert_end_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 查詢效能測試</span>
</span></span><span style="display:flex;"><span>query_times <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">100</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 隨機生成查詢的時間區間</span>
</span></span><span style="display:flex;"><span>    start_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">0</span>, time_range<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">30</span>))  <span style="color:#8b949e;font-style:italic"># 最大值為 - 30 為 30 分鐘</span>
</span></span><span style="display:flex;"><span>    end_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_timestamp <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">30</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    product_names <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">1</span>, product_size)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span> <span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">10</span>)]  <span style="color:#8b949e;font-style:italic"># 隨機查詢 10 個商品</span>
</span></span><span style="display:flex;"><span>    query <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;product_name&#34;</span>: {<span style="color:#a5d6ff">&#34;$in&#34;</span>: product_names},
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;timestamp&#34;</span>: {<span style="color:#a5d6ff">&#34;$gt&#34;</span>: start_timestamp, <span style="color:#a5d6ff">&#34;$lte&#34;</span>: end_timestamp}
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 執行查詢</span>
</span></span><span style="display:flex;"><span>    start_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    result <span style="color:#ff7b72;font-weight:bold">=</span> db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>find(query)
</span></span><span style="display:flex;"><span>    end_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    query_times<span style="color:#ff7b72;font-weight:bold">.</span>append(end_query <span style="color:#ff7b72;font-weight:bold">-</span> start_query)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB 寫入時間: </span><span style="color:#a5d6ff">{</span>insert_end_time <span style="color:#ff7b72;font-weight:bold">-</span> insert_start_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB 寫入總量: </span><span style="color:#a5d6ff">{</span>total_records<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 條&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計算平均查詢時間</span>
</span></span><span style="display:flex;"><span>average_query_time <span style="color:#ff7b72;font-weight:bold">=</span> sum(query_times) <span style="color:#ff7b72;font-weight:bold">/</span> len(query_times)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB 平均查詢時間: </span><span style="color:#a5d6ff">{</span>average_query_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>client<span style="color:#ff7b72;font-weight:bold">.</span>close()
</span></span></code></pre></div><p>下面為 MongoDB 測試方案 1 的結果:</p>
<ul>
<li><strong>寫入時間</strong>: 6.8597 秒</li>
<li><strong>平均查詢時間</strong>: 6.3467e-06 秒</li>
<li><strong>資料大小</strong>: 97.8 MB</li>
</ul>
<hr>
<h2 id="-mongodb-測試方案-2--time-series-collection">MongoDB 測試方案 2 ── Time Series Collection<a class="h-anchor" href="#-mongodb-%e6%b8%ac%e8%a9%a6%e6%96%b9%e6%a1%88-2--time-series-collection" aria-label="這個段落的連結"></a></h2>
<p>第二個方案則是採用 MongoDB Time Series Collection 的設計，設定如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;product_stats&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">timeseries={</span>
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;timeField&#34;</span>: <span style="color:#a5d6ff">&#34;timestamp&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;metaField&#34;</span>: <span style="color:#a5d6ff">&#34;product_name&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#7ee787">&#34;granularity&#34;</span>: <span style="color:#a5d6ff">&#34;minutes&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span><span style="color:#f85149">}</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">pymongo</span> <span style="color:#ff7b72">import</span> MongoClient, ASCENDING
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">random</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> <span style="color:#ff7b72">time</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">from</span> <span style="color:#ff7b72">datetime</span> <span style="color:#ff7b72">import</span> datetime, timedelta
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># MongoDB 連接設置</span>
</span></span><span style="display:flex;"><span>client <span style="color:#ff7b72;font-weight:bold">=</span> MongoClient(<span style="color:#a5d6ff">&#39;mongodb://localhost:27017/&#39;</span>)
</span></span><span style="display:flex;"><span>db <span style="color:#ff7b72;font-weight:bold">=</span> client<span style="color:#ff7b72;font-weight:bold">.</span>testdb
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 清空集合並創建新集合</span>
</span></span><span style="display:flex;"><span>db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>drop()
</span></span><span style="display:flex;"><span>db<span style="color:#ff7b72;font-weight:bold">.</span>create_collection(
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#34;product_stats&#34;</span>,
</span></span><span style="display:flex;"><span>    timeseries<span style="color:#ff7b72;font-weight:bold">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;timeField&#34;</span>: <span style="color:#a5d6ff">&#34;timestamp&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;metaField&#34;</span>: <span style="color:#a5d6ff">&#34;product_name&#34;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;granularity&#34;</span>: <span style="color:#a5d6ff">&#34;minutes&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入數據</span>
</span></span><span style="display:flex;"><span>start_time <span style="color:#ff7b72;font-weight:bold">=</span> datetime<span style="color:#ff7b72;font-weight:bold">.</span>now()
</span></span><span style="display:flex;"><span>insert_batch_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span>  <span style="color:#8b949e;font-style:italic"># 每批次寫入數據量</span>
</span></span><span style="display:flex;"><span>product_size <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2000</span> <span style="color:#8b949e;font-style:italic"># 要測試的商品總量</span>
</span></span><span style="display:flex;"><span>total_records <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>  <span style="color:#8b949e;font-style:italic"># 記錄寫入的數據總量</span>
</span></span><span style="display:flex;"><span>time_range <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">8</span> <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">60</span> <span style="color:#8b949e;font-style:italic"># 總時間 8 hours</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 開始計時</span>
</span></span><span style="display:flex;"><span>insert_start_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 建立數據並批量寫入</span>
</span></span><span style="display:flex;"><span>batch <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> minute <span style="color:#ff7b72;font-weight:bold">in</span> range(time_range):  <span style="color:#8b949e;font-style:italic"># 8 小時</span>
</span></span><span style="display:flex;"><span>    timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>minute)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> i <span style="color:#ff7b72;font-weight:bold">in</span> range(product_size):  <span style="color:#8b949e;font-style:italic"># 每分鐘要寫入的商品紀錄</span>
</span></span><span style="display:flex;"><span>        product_name <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>  <span style="color:#8b949e;font-style:italic"># 模擬 product_size 種不同商品</span>
</span></span><span style="display:flex;"><span>        data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;統計資料</span><span style="color:#a5d6ff">{</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span>
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        batch<span style="color:#ff7b72;font-weight:bold">.</span>append({
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;timestamp&#34;</span>: timestamp,
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;product_name&#34;</span>: product_name,
</span></span><span style="display:flex;"><span>            <span style="color:#a5d6ff">&#34;data&#34;</span>: data
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>        total_records <span style="color:#ff7b72;font-weight:bold">+=</span> <span style="color:#a5d6ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#8b949e;font-style:italic"># 批量寫入</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">if</span> len(batch) <span style="color:#ff7b72;font-weight:bold">&gt;=</span> insert_batch_size:
</span></span><span style="display:flex;"><span>            db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>insert_many(batch)
</span></span><span style="display:flex;"><span>            batch<span style="color:#ff7b72;font-weight:bold">.</span>clear()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 寫入剩餘的數據</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> batch:
</span></span><span style="display:flex;"><span>    db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>insert_many(batch)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計時結束</span>
</span></span><span style="display:flex;"><span>insert_end_time <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 查詢性能測試</span>
</span></span><span style="display:flex;"><span>query_times <span style="color:#ff7b72;font-weight:bold">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">100</span>):
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 隨機生成查詢的時間區間</span>
</span></span><span style="display:flex;"><span>    start_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_time <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">0</span>, time_range<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">30</span>))  <span style="color:#8b949e;font-style:italic"># 最大值為 - 30 為 30 分鐘</span>
</span></span><span style="display:flex;"><span>    end_timestamp <span style="color:#ff7b72;font-weight:bold">=</span> start_timestamp <span style="color:#ff7b72;font-weight:bold">+</span> timedelta(minutes<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">30</span>)
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    product_names <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;商品名稱</span><span style="color:#a5d6ff">{</span>random<span style="color:#ff7b72;font-weight:bold">.</span>randint(<span style="color:#a5d6ff">1</span>, product_size)<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">&#34;</span> <span style="color:#ff7b72">for</span> _ <span style="color:#ff7b72;font-weight:bold">in</span> range(<span style="color:#a5d6ff">10</span>)]  <span style="color:#8b949e;font-style:italic"># 隨機查詢 10 個商品</span>
</span></span><span style="display:flex;"><span>    query <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;product_name&#34;</span>: {<span style="color:#a5d6ff">&#34;$in&#34;</span>: product_names},
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#34;timestamp&#34;</span>: {<span style="color:#a5d6ff">&#34;$gt&#34;</span>: start_timestamp, <span style="color:#a5d6ff">&#34;$lte&#34;</span>: end_timestamp}
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># 執行查詢</span>
</span></span><span style="display:flex;"><span>    start_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    result <span style="color:#ff7b72;font-weight:bold">=</span> db<span style="color:#ff7b72;font-weight:bold">.</span>product_stats<span style="color:#ff7b72;font-weight:bold">.</span>find(query)
</span></span><span style="display:flex;"><span>    end_query <span style="color:#ff7b72;font-weight:bold">=</span> time<span style="color:#ff7b72;font-weight:bold">.</span>time()
</span></span><span style="display:flex;"><span>    query_times<span style="color:#ff7b72;font-weight:bold">.</span>append(end_query <span style="color:#ff7b72;font-weight:bold">-</span> start_query)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB Time Series Collection 寫入時間: </span><span style="color:#a5d6ff">{</span>insert_end_time <span style="color:#ff7b72;font-weight:bold">-</span> insert_start_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB Time Series Collection 寫入總量: </span><span style="color:#a5d6ff">{</span>total_records<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 條&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 計算平均查詢時間</span>
</span></span><span style="display:flex;"><span>average_query_time <span style="color:#ff7b72;font-weight:bold">=</span> sum(query_times) <span style="color:#ff7b72;font-weight:bold">/</span> len(query_times)
</span></span><span style="display:flex;"><span>print(<span style="color:#79c0ff">f</span><span style="color:#a5d6ff">&#34;MongoDB Time Series Collection 平均查詢時間: </span><span style="color:#a5d6ff">{</span>average_query_time<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> 秒&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>client<span style="color:#ff7b72;font-weight:bold">.</span>close()
</span></span></code></pre></div><p>下面為 MongoDB 測試方案 2 的結果:</p>
<ul>
<li><strong>寫入時間</strong>: 31.0204 秒</li>
<li><strong>平均查詢時間</strong>: 6.7949e-06 秒</li>
<li><strong>資料大小</strong>: 53.1 MB</li>
</ul>
<h2 id="mongodb-測試方案-3--embedded-documents">MongoDB 測試方案 3 ── Embedded Documents<a class="h-anchor" href="#mongodb-%e6%b8%ac%e8%a9%a6%e6%96%b9%e6%a1%88-3--embedded-documents" aria-label="這個段落的連結"></a></h2>
<p>第三個方案是上篇文章的最終方案，將統計數據紀錄到商品名稱（product_name）底下，並對「商品名稱(product_name)、紀錄時間(timestamp)」加上了 index。數據格式如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;_id&#34;</span> : <span style="color:#f85149">ObjectId(</span><span style="color:#a5d6ff">&#34;6651a4d6329cedfc74f40dde&#34;</span><span style="color:#f85149">)</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;product_name&#34;</span> : <span style="color:#a5d6ff">&#34;商品名稱0&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;stats&#34;</span> : [
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span> : <span style="color:#f85149">ISODate(</span><span style="color:#a5d6ff">&#34;2024-05-25T16:44:06.728+0000&#34;</span><span style="color:#f85149">)</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span> : <span style="color:#a5d6ff">&#34;統計資料0&#34;</span>
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span> : <span style="color:#f85149">ISODate(</span><span style="color:#a5d6ff">&#34;2024-05-25T16:45:06.728+0000&#34;</span><span style="color:#f85149">)</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span> : <span style="color:#a5d6ff">&#34;統計資料0&#34;</span>
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">...</span>
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#f85149">from</span> <span style="color:#f85149">pymongo</span> <span style="color:#f85149">import</span> <span style="color:#f85149">MongoClient,</span> <span style="color:#f85149">ASCENDING,</span> <span style="color:#f85149">UpdateOne</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">import</span> <span style="color:#f85149">random</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">import</span> <span style="color:#f85149">time</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">from</span> <span style="color:#f85149">datetime</span> <span style="color:#f85149">import</span> <span style="color:#f85149">datetime,</span> <span style="color:#f85149">timedelta</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">MongoDB</span> <span style="color:#f85149">連接設置</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">client</span> <span style="color:#f85149">=</span> <span style="color:#f85149">MongoClient(&#39;mongodb:</span><span style="color:#8b949e;font-style:italic">//localhost:27017/&#39;)
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">db</span> <span style="color:#f85149">=</span> <span style="color:#f85149">client.testdb</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">清空集合並創建新集合</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">db.product_stats.drop()</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">db.create_collection(&#39;product_stats&#39;)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">加入</span> <span style="color:#f85149">index</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">db.product_stats.create_index(</span>[<span style="color:#f85149">(</span><span style="color:#a5d6ff">&#34;product_name&#34;</span>, <span style="color:#f85149">ASCENDING)</span>, <span style="color:#f85149">(</span><span style="color:#a5d6ff">&#34;stats.timestamp&#34;</span>, <span style="color:#f85149">ASCENDING)</span>]<span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">寫入數據</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">start_time</span> <span style="color:#f85149">=</span> <span style="color:#f85149">datetime.now()</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">insert_batch_size</span> <span style="color:#f85149">=</span> <span style="color:#a5d6ff">2000</span>  <span style="color:#f85149">#</span> <span style="color:#f85149">每批次寫入數據量</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">product_size</span> <span style="color:#f85149">=</span> <span style="color:#a5d6ff">2000</span> <span style="color:#f85149">#</span> <span style="color:#f85149">要測試的商品總量</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">total_records</span> <span style="color:#f85149">=</span> <span style="color:#a5d6ff">0</span>  <span style="color:#f85149">#</span> <span style="color:#f85149">記錄寫入的數據總量</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">time_range</span> <span style="color:#f85149">=</span> <span style="color:#a5d6ff">8</span> <span style="color:#f85149">*</span> <span style="color:#a5d6ff">60</span> <span style="color:#f85149">#</span> <span style="color:#f85149">總時間</span> <span style="color:#a5d6ff">8</span> <span style="color:#f85149">hours</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">開始計時</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">insert_start_time</span> <span style="color:#f85149">=</span> <span style="color:#f85149">time.time()</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">建立數據並批量寫入</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">product_dict</span> <span style="color:#f85149">=</span> {}
</span></span><span style="display:flex;"><span><span style="color:#f85149">for</span> <span style="color:#f85149">minute</span> <span style="color:#f85149">in</span> <span style="color:#f85149">range(time_range):</span>  <span style="color:#f85149">#</span> <span style="color:#a5d6ff">8</span> <span style="color:#f85149">小時</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">timestamp</span> <span style="color:#f85149">=</span> <span style="color:#f85149">start_time</span> <span style="color:#f85149">+</span> <span style="color:#f85149">timedelta(minutes=minute)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">for</span> <span style="color:#f85149">i</span> <span style="color:#f85149">in</span> <span style="color:#f85149">range(product_size):</span>  <span style="color:#f85149">#</span> <span style="color:#f85149">每分鐘要寫入的商品紀錄</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">product_name</span> <span style="color:#f85149">=</span> <span style="color:#f85149">f</span><span style="color:#a5d6ff">&#34;商品名稱{i}&#34;</span>  <span style="color:#f85149">#</span> <span style="color:#f85149">模擬</span> <span style="color:#f85149">product_size</span> <span style="color:#f85149">種不同商品</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">stats</span> <span style="color:#f85149">=</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span>: <span style="color:#f85149">timestamp</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span>: <span style="color:#f85149">f</span><span style="color:#a5d6ff">&#34;統計資料{i}&#34;</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">if</span> <span style="color:#f85149">product_name</span> <span style="color:#f85149">not</span> <span style="color:#f85149">in</span> <span style="color:#f85149">product_dict:</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">product_dict</span>[<span style="color:#f85149">product_name</span>] <span style="color:#f85149">=</span> []
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">product_dict</span>[<span style="color:#f85149">product_name</span>]<span style="color:#f85149">.append(stats)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">total_records</span> <span style="color:#f85149">+=</span> <span style="color:#a5d6ff">1</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">#</span> <span style="color:#f85149">批量更新</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">if</span> <span style="color:#f85149">total_records</span> <span style="color:#f85149">%</span> <span style="color:#f85149">insert_batch_size</span> <span style="color:#f85149">==</span> <span style="color:#a5d6ff">0</span><span style="color:#f85149">:</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">operations</span> <span style="color:#f85149">=</span> [
</span></span><span style="display:flex;"><span>                <span style="color:#f85149">UpdateOne(</span>
</span></span><span style="display:flex;"><span>                    {<span style="color:#7ee787">&#34;product_name&#34;</span>: <span style="color:#f85149">p_name</span>},
</span></span><span style="display:flex;"><span>                    {<span style="color:#7ee787">&#34;$push&#34;</span>: {<span style="color:#7ee787">&#34;stats&#34;</span>: {<span style="color:#7ee787">&#34;$each&#34;</span>: <span style="color:#f85149">stats_array</span>}}},
</span></span><span style="display:flex;"><span>                    <span style="color:#f85149">upsert=True</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f85149">for</span> <span style="color:#f85149">p_name</span>, <span style="color:#f85149">stats_array</span> <span style="color:#f85149">in</span> <span style="color:#f85149">product_dict.items()</span>
</span></span><span style="display:flex;"><span>            ]
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">db.product_stats.bulk_write(operations)</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">product_dict.clear()</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">寫入剩餘的數據</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">if</span> <span style="color:#f85149">product_dict:</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">operations</span> <span style="color:#f85149">=</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">UpdateOne(</span>
</span></span><span style="display:flex;"><span>            {<span style="color:#7ee787">&#34;product_name&#34;</span>: <span style="color:#f85149">p_name</span>},
</span></span><span style="display:flex;"><span>            {<span style="color:#7ee787">&#34;$push&#34;</span>: {<span style="color:#7ee787">&#34;stats&#34;</span>: {<span style="color:#7ee787">&#34;$each&#34;</span>: <span style="color:#f85149">stats_array</span>}}},
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">upsert=True</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f85149">for</span> <span style="color:#f85149">p_name</span>, <span style="color:#f85149">stats_array</span> <span style="color:#f85149">in</span> <span style="color:#f85149">product_dict.items()</span>
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">db.product_stats.bulk_write(operations)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">計時結束</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">insert_end_time</span> <span style="color:#f85149">=</span> <span style="color:#f85149">time.time()</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">查詢性能測試</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">query_times</span> <span style="color:#f85149">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#f85149">for</span> <span style="color:#f85149">_</span> <span style="color:#f85149">in</span> <span style="color:#f85149">range(</span><span style="color:#a5d6ff">100</span><span style="color:#f85149">):</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">#</span> <span style="color:#f85149">隨機生成查詢的時間區間</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">start_timestamp</span> <span style="color:#f85149">=</span> <span style="color:#f85149">start_time</span> <span style="color:#f85149">+</span> <span style="color:#f85149">timedelta(minutes=random.randint(</span><span style="color:#a5d6ff">0</span><span style="color:#f85149">,</span> <span style="color:#f85149">time_range</span><span style="color:#a5d6ff">-30</span><span style="color:#f85149">))</span>  <span style="color:#f85149">#</span> <span style="color:#f85149">最大值為</span> <span style="color:#f85149">-</span> <span style="color:#a5d6ff">30</span> <span style="color:#f85149">為</span> <span style="color:#a5d6ff">30</span> <span style="color:#f85149">分鐘</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">end_timestamp</span> <span style="color:#f85149">=</span> <span style="color:#f85149">start_timestamp</span> <span style="color:#f85149">+</span> <span style="color:#f85149">timedelta(minutes=</span><span style="color:#a5d6ff">30</span><span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">product_names</span> <span style="color:#f85149">=</span> [<span style="color:#f85149">f</span><span style="color:#a5d6ff">&#34;商品名稱{random.randint(1, product_size)}&#34;</span> <span style="color:#f85149">for</span> <span style="color:#f85149">_</span> <span style="color:#f85149">in</span> <span style="color:#f85149">range(</span><span style="color:#a5d6ff">10</span><span style="color:#f85149">)</span>]  <span style="color:#f85149">#</span> <span style="color:#f85149">隨機查詢</span> <span style="color:#a5d6ff">10</span> <span style="color:#f85149">個商品</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">aggregate_query</span> <span style="color:#f85149">=</span> [
</span></span><span style="display:flex;"><span>        {<span style="color:#7ee787">&#34;$match&#34;</span>: {<span style="color:#7ee787">&#34;product_name&#34;</span>: {<span style="color:#7ee787">&#34;$in&#34;</span>: <span style="color:#f85149">product_names</span>}}},
</span></span><span style="display:flex;"><span>        {<span style="color:#7ee787">&#34;$unwind&#34;</span>: <span style="color:#a5d6ff">&#34;$stats&#34;</span>},
</span></span><span style="display:flex;"><span>        {<span style="color:#7ee787">&#34;$match&#34;</span>: {<span style="color:#7ee787">&#34;stats.timestamp&#34;</span>: {<span style="color:#7ee787">&#34;$gt&#34;</span>: <span style="color:#f85149">start_timestamp</span>, <span style="color:#7ee787">&#34;$lte&#34;</span>: <span style="color:#f85149">end_timestamp</span>}}},
</span></span><span style="display:flex;"><span>        {<span style="color:#7ee787">&#34;$project&#34;</span>: {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;_id&#34;</span>: <span style="color:#a5d6ff">0</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;product_name&#34;</span>: <span style="color:#a5d6ff">1</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span>: <span style="color:#a5d6ff">&#34;$stats.timestamp&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span>: <span style="color:#a5d6ff">&#34;$stats.data&#34;</span>
</span></span><span style="display:flex;"><span>        }}
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">#</span> <span style="color:#f85149">執行查詢</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">start_query</span> <span style="color:#f85149">=</span> <span style="color:#f85149">time.time()</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">result</span> <span style="color:#f85149">=</span> <span style="color:#f85149">db.product_stats.aggregate(aggregate_query)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">end_query</span> <span style="color:#f85149">=</span> <span style="color:#f85149">time.time()</span>
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#f85149">query_times.append(end_query</span> <span style="color:#f85149">-</span> <span style="color:#f85149">start_query)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">print(f</span><span style="color:#a5d6ff">&#34;MongoDB 寫入時間: {insert_end_time - insert_start_time} 秒&#34;</span><span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">print(f</span><span style="color:#a5d6ff">&#34;MongoDB 寫入總量: {total_records} 條&#34;</span><span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">#</span> <span style="color:#f85149">計算平均查詢時間</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">average_query_time</span> <span style="color:#f85149">=</span> <span style="color:#f85149">sum(query_times)</span> <span style="color:#f85149">/</span> <span style="color:#f85149">len(query_times)</span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">print(f</span><span style="color:#a5d6ff">&#34;MongoDB 平均查詢時間: {average_query_time} 秒&#34;</span><span style="color:#f85149">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f85149">client.close()</span>
</span></span></code></pre></div><p>下面為 MongoDB 測試方案 3 的結果:</p>
<ul>
<li><strong>寫入時間</strong>: 166.6052 秒</li>
<li><strong>平均查詢時間</strong>: 0.0032 秒</li>
<li><strong>資料大小</strong>: 53.1 MB</li>
</ul>
<hr>
<h2 id="-結論想象跟實際的落差">結論：想象跟實際的落差<a class="h-anchor" href="#-%e7%b5%90%e8%ab%96%e6%83%b3%e8%b1%a1%e8%b7%9f%e5%af%a6%e9%9a%9b%e7%9a%84%e8%90%bd%e5%b7%ae" aria-label="這個段落的連結"></a></h2>
<p>下面我先用 Table 的方式，向大家展示這四個方案的測試結果:</p>
<p><img src="https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/images/mongodb-vs-mysql-index-performance-02.webp" width="1028" height="284" alt="結論：想象跟實際的落差（圖 2）" loading="lazy" decoding="async"></p>
<p>從寫入與讀取效能的角度來說，MongoDB 單筆 index 的方案就夠了；但如果想要壓縮資料大小，可以考慮 Time Series Collection 的方案。</p>
<p>至於上一篇文章原本打算使用的 Embedded Documents 方案，在寫入效能方面真的不太好，商品種類越多效能越差，真的是沒實驗過不知道。</p>
<p>至於 MySQL 就是一個安全牌，除非有很極致的需求，不然這個方案沒什麼大缺點。</p>
<p>下面整理一下我這次的實驗心得吧:</p>
<ul>
<li>相同資料庫，在資料結構不同的狀況下，效能會有巨大差異。</li>
<li>如果只看搜尋效率，MongoDB 大多數情境下會贏</li>
<li>其實 MongoDB 跟 MySQL 都能處理這個案例，就看實際業務更關注哪塊的效能。</li>
</ul>
<p>當然小弟的實驗可能有許多考量不足的地方，歡迎各位大神留言交流，謝謝。</p>
<a class="linkcard" href="https://deanlin.net/tags/database/">
  <span class="lc-body">
    <span class="lc-title">Database</span>
    <span class="lc-desc">Edit description</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>當資料庫遇到瓶頸，你會如何處理？ ── 下班有約系列文</title>
      <link>https://deanlin.net/blog/202405/database-bottlenecks/</link>
      <pubDate>Wed, 22 May 2024 16:17:09 +0800</pubDate>
      <guid>https://deanlin.net/blog/202405/database-bottlenecks/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>除非真的撐不住了，否則我覺得應該沒幾間公司會想換資料庫。 今天跟大家分享一個資料庫遇到瓶頸必須遷移的的案例。</description>
      <media:content url="https://deanlin.net/blog/202405/database-bottlenecks/images/database-bottlenecks-og_hu_ccbbf47985bdbe62.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>除非真的撐不住了，否則我覺得應該沒幾間公司會想換資料庫。</p>
<p>今天跟大家分享一個資料庫遇到瓶頸必須遷移的的案例。</p>
<p>過去朋友的公司用 Redis 來紀錄每件商品每分鐘的瀏覽數、庫存等資訊，以此來呈現即時的商品統計圖表（資料只要保存 1 個月）。</p>
<p>但隨著商品種類日漸增加（種類高達數萬），Redis 這類用記憶體（RAM）儲存資料的系統開始遇到了瓶頸。</p>
<p>過去還可以用課金增加記憶體的方式來解決，但最新的需求是將資料保存的時限拉長到一年（統計圖表時間搜尋範圍）。</p>
<p>而每天的資料量大約有 250MB，30 天還好說，變成 365 天成本就真的太可怕了。</p>
<p>於是團隊就開始討論合適的遷移方案。</p>
<p><img src="https://deanlin.net/blog/202405/database-bottlenecks/images/database-bottlenecks-01.webp" width="1144" height="642" alt="當資料庫遇到瓶頸，你會如何處理？ ── 下班有約系列文（圖 1）" fetchpriority="high" decoding="async"></p>
<h2 id="方案一redis-僅保留-1-個月的資料量過期的轉移到其他資料庫">方案一：Redis 僅保留 1 個月的資料量，過期的轉移到其他資料庫<a class="h-anchor" href="#%e6%96%b9%e6%a1%88%e4%b8%80redis-%e5%83%85%e4%bf%9d%e7%95%99-1-%e5%80%8b%e6%9c%88%e7%9a%84%e8%b3%87%e6%96%99%e9%87%8f%e9%81%8e%e6%9c%9f%e7%9a%84%e8%bd%89%e7%a7%bb%e5%88%b0%e5%85%b6%e4%bb%96%e8%b3%87%e6%96%99%e5%ba%ab" aria-label="這個段落的連結"></a></h2>
<p>最初的方案是增加一個排程腳本，每天定時將快過期的資料寫到其他資料庫保存。</p>
<p>之所以讓 Redis 保留一個月的資料量，因為系統默認顯示當前一個月的統計資料，存取次數較高。</p>
<p>但這麼做有許多缺點，比如：</p>
<ul>
<li>如果使用者搜尋的時間點跨月，那後端的程式邏輯就會比較複雜。</li>
<li>需要同時維護兩套程式碼與資料庫，可能會造成未來隱憂（ex：版本升級、錯誤釐清）。</li>
<li>對團隊成員的技術水平有更高的要求，可能會提升招募的難度。</li>
</ul>
<p>所以這個方案提出後當下就否決了。</p>
<hr>
<h2 id="方案二將資料遷移到關連式資料庫">方案二：將資料遷移到關連式資料庫<a class="h-anchor" href="#%e6%96%b9%e6%a1%88%e4%ba%8c%e5%b0%87%e8%b3%87%e6%96%99%e9%81%b7%e7%a7%bb%e5%88%b0%e9%97%9c%e9%80%a3%e5%bc%8f%e8%b3%87%e6%96%99%e5%ba%ab" aria-label="這個段落的連結"></a></h2>
<p>因為大部分的團隊成員都比較熟悉關聯式資料庫（ex: MySQL）。</p>
<p>所以大家想說要不就遷移到關聯式資料庫吧，這樣改版要花的時間成本較低。</p>
<p>但研究了一下發現可行性較低，因為：</p>
<ul>
<li>如果不設計索引（index），搜尋效率會低到可怕。</li>
<li>但設計索引（index）後，因為每分鐘要寫入大量資料，所以會影響到寫入效能。</li>
<li>考量到未來統計報告可能需要更多資料，關聯式資料庫並不適合動態擴充欄位。</li>
</ul>
<hr>
<h2 id="方案三將資料遷移到非關連式資料庫nosql">方案三：將資料遷移到非關連式資料庫（NoSQL）<a class="h-anchor" href="#%e6%96%b9%e6%a1%88%e4%b8%89%e5%b0%87%e8%b3%87%e6%96%99%e9%81%b7%e7%a7%bb%e5%88%b0%e9%9d%9e%e9%97%9c%e9%80%a3%e5%bc%8f%e8%b3%87%e6%96%99%e5%ba%abnosql" aria-label="這個段落的連結"></a></h2>
<p>幾經考慮後，最終的方案是選擇 MongoDB 這個 NoSQL。</p>
<p>主要原因是 MongoDB 無論在讀取、寫入，還是動態擴充欄位方面都能滿足需求。</p>
<p>在決定使用 MongoDB 後，接下來對資料結構做了一下調整。</p>
<p>在 Redis 中，原本將統計資料儲存在 Sorted Set 中，並使用 Hash 結構來存儲每個 timestamp 下的商品統計資料。結構如下：</p>
<pre tabindex="0"><code>Sorted Set: timestamps
Member: timestamp (e.g., 1620000000)
Hash: hash:timestamp (e.g., hash:1620000000) 
{
    &#34;商品名稱1&#34;: &#34;統計資料1&#34;,
    &#34;商品名稱2&#34;: &#34;統計資料2&#34;,
    ...
}
</code></pre><p>然而，這樣的設計有些問題。因為實際頁面通常只顯示最多 20 筆商品的統計資料；但為了達成這個目標，我們需要先抓取區間內的 timestamp，然後再找到對應的 Key（商品名稱）。</p>
<p>在 Redis 這麼做還 Ok，但如果轉換到 MongoDB，效率可能會不理想。</p>
<p>因此，在遷移到 MongoDB 時重新設計了資料結構，將商品作為 collection 的主要文件，timestamp 與統計資料作為內嵌文件。</p>
<p>新的 MongoDB 設計如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;商品名稱&#34;</span>: <span style="color:#a5d6ff">&#34;商品名稱1&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;統計資料&#34;</span>: [
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span>: <span style="color:#a5d6ff">&#34;1620000000&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span>: <span style="color:#a5d6ff">&#34;統計資料1&#34;</span>
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;timestamp&#34;</span>: <span style="color:#a5d6ff">&#34;16200000100&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#7ee787">&#34;data&#34;</span>: <span style="color:#a5d6ff">&#34;統計資料2&#34;</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>在這個設計中，我們將商品名稱設為 index，以提高查詢特定商品統計資料的效率。</p>
<blockquote>
<p>小提醒：經過實驗後，這個資料結構並不是最理想的，詳情參考這篇文章（<a href="https://deanlin.net/blog/202405/mongodb-vs-mysql-index-performance/">MongoDB 與 MySQL 建立 Index 對 Read/Write 的效能影響</a>）</p>
</blockquote>
<p>另外考量到時間搜尋範圍可能很大（ex: 4 個月的趨勢），面對大範圍的資料顯示，會以小時的統計資料呈現。</p>
<p>以上就是朋友跟我分享的資料庫遷移案例，希望對大家有幫助，如果有更好的方案，也歡迎留言分享。</p>
<a class="linkcard" href="https://deanlin.net/tags/database/">
  <span class="lc-body">
    <span class="lc-title">Database</span>
    <span class="lc-desc">Edit description</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>PHP 陣列操作：那些讓你意外的小陷阱(array_unique、array_filter)</title>
      <link>https://deanlin.net/blog/202310/php-array-pitfalls/</link>
      <pubDate>Wed, 18 Oct 2023 11:25:57 +0800</pubDate>
      <guid>https://deanlin.net/blog/202310/php-array-pitfalls/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>我發現許多從其他程式語言轉來 PHP 的開發者，經常會在操作陣列時碰到意料之外的狀況（我就是受害者）。今天我要來談談 PHP 中的 array_unique 和 array_filter 函數，以及它們的行為與你想像中可能存在的差異。</description>
      <media:content url="https://deanlin.net/blog/202310/php-array-pitfalls/images/php-array-pitfalls-og_hu_cf8472b68b605288.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>我發現許多從其他程式語言轉來 PHP 的開發者，經常會在操作陣列時碰到意料之外的狀況（我就是受害者）。今天我要來談談 PHP 中的 <code>array_unique</code> 和 <code>array_filter</code> 函數，以及它們的行為與你想像中可能存在的差異。</p>
<p><img src="https://deanlin.net/blog/202310/php-array-pitfalls/images/php-array-pitfalls-01.webp" width="1056" height="590" alt="PHP 陣列操作：那些讓你意外的小陷阱(array_unique、array_filter)（圖 1）" fetchpriority="high" decoding="async"></p>
<h2 class="h-nomark" id="-array_filter-的索引行為">🎭 Array_filter 的索引行為<a class="h-anchor" href="#-array_filter-%e7%9a%84%e7%b4%a2%e5%bc%95%e8%a1%8c%e7%82%ba" aria-label="這個段落的連結"></a></h2>
<p>在 JavaScript 的世界中，我們用 <code>filter</code> 來過濾陣列，結果非常直觀。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">let</span> arr <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">4</span>];
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">let</span> filteredArr <span style="color:#ff7b72;font-weight:bold">=</span> arr.filter(value =&gt; value <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2</span>);
</span></span><span style="display:flex;"><span>console.log(filteredArr); <span style="color:#8b949e;font-style:italic">// 看吧！直接返回 [3, 4]
</span></span></span></code></pre></div><p>但換到 PHP，<code>array_filter</code> 卻有點不一樣。使用它之後，你會發現原始的索引還在：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#79c0ff">$arr</span> <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">array</span>(<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">4</span>);
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">$filteredArr</span> <span style="color:#ff7b72;font-weight:bold">=</span> array_filter(<span style="color:#79c0ff">$arr</span>, <span style="color:#ff7b72">function</span>(<span style="color:#79c0ff">$value</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">$value</span> <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2</span>;
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>print_r(<span style="color:#79c0ff">$filteredArr</span>); <span style="color:#8b949e;font-style:italic">// 喔喔！結果是 Array ( [2] =&gt; 3 [3] =&gt; 4 )
</span></span></span></code></pre></div><p><strong>這意味著如果你嘗試在過濾後的陣列上取得 <code>$filteredArr[0]</code>，會得到一個未定義的索引錯誤。</strong></p>
<hr>
<h2 class="h-nomark" id="-array_unique-的特殊之處">🎩 Array_unique 的特殊之處<a class="h-anchor" href="#-array_unique-%e7%9a%84%e7%89%b9%e6%ae%8a%e4%b9%8b%e8%99%95" aria-label="這個段落的連結"></a></h2>
<p><code>array_unique</code> 函數在 PHP 中用於移除陣列中的重複值。</p>
<p>但小心！它也不會重新排列索引：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#79c0ff">$arr</span> <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">array</span>(<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">4</span>);
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">$uniqueArr</span> <span style="color:#ff7b72;font-weight:bold">=</span> array_unique(<span style="color:#79c0ff">$arr</span>);
</span></span><span style="display:flex;"><span>print_r(<span style="color:#79c0ff">$uniqueArr</span>); <span style="color:#8b949e;font-style:italic">// 哇！這是什麼魔法？Array ( [0] =&gt; 1 [1] =&gt; 2 [3] =&gt; 3 [5] =&gt; 4 )
</span></span></span></code></pre></div><p>同樣的，如果你直接取得 <code>$uniqueArr[2]</code>會讓你再次掉入那恐怖的陷阱中！</p>
<hr>
<h2 class="h-nomark" id="-如何應對">🌈 如何應對？<a class="h-anchor" href="#-%e5%a6%82%e4%bd%95%e6%87%89%e5%b0%8d" aria-label="這個段落的連結"></a></h2>
<p>為了避免這些問題，建議在使用這些函數後，用 <code>array_values</code> 函數重設索引：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#79c0ff">$arr</span> <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">array</span>(<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">2</span>, <span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">4</span>);
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">$filteredArr</span> <span style="color:#ff7b72;font-weight:bold">=</span> array_filter(<span style="color:#79c0ff">$arr</span>, <span style="color:#ff7b72">function</span>(<span style="color:#79c0ff">$value</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#79c0ff">$value</span> <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2</span>;
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">$reIndexedArr</span> <span style="color:#ff7b72;font-weight:bold">=</span> array_values(<span style="color:#79c0ff">$filteredArr</span>);
</span></span><span style="display:flex;"><span>print_r(<span style="color:#79c0ff">$reIndexedArr</span>); <span style="color:#8b949e;font-style:italic">// Array ( [0] =&gt; 3 [1] =&gt; 4 )
</span></span></span></code></pre></div><p>這樣，你就可以放心地存取 <code>$reIndexedArr[0]</code> 而不必擔心會遭遇未定義的索引。</p>
<hr>
<h2 class="h-nomark" id="-總結">🎉 總結<a class="h-anchor" href="#-%e7%b8%bd%e7%b5%90" aria-label="這個段落的連結"></a></h2>
<p>雖然 PHP 的陣列操作函數在某些方面與其他語言有所不同，但只要你了解這些差異並採取適當的預防措施，就可以順利避免可能的錯誤。</p>
<p>希望這篇文章能夠幫助你更深入地了解 PHP 的陣列操作 🚀</p>
]]></content:encoded>
    </item>
    <item>
      <title>使用 Pretty QR Code 讓你擁有高質感的 QR Code</title>
      <link>https://deanlin.net/blog/202305/pretty-qr-code/</link>
      <pubDate>Thu, 18 May 2023 22:10:27 +0800</pubDate>
      <guid>https://deanlin.net/blog/202305/pretty-qr-code/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>你還在使用醜醜的 QR Code 嗎？今天筆者想跟大家分享「lihi 短網址」的小工具，只要 5 步驟就能創造自己獨特的 QR Code。</description>
      <media:content url="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-og_hu_76352d94e4d9f006.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>你還在使用醜醜的 QR Code 嗎？今天筆者想跟大家分享「lihi 短網址」的小工具，只要 5 步驟就能創造自己獨特的 QR Code。</p>
<p>如果有在經營自媒體，一個漂亮的 QR Code 不僅能提升品牌形象，還能幫你把讀者從一個平台帶到另一個平台。</p>
<p>你也可以把 QR Code 放在你的名片、海報，甚至是社群媒體上，吸引更多的讀者。</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-01.webp" width="1400" height="735" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 1）" fetchpriority="high" decoding="async"></p>
<p><strong>SETP 1</strong>：註冊 <a href="https://lihi.io/">lihi</a></p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-02.webp" width="1400" height="783" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 2）" loading="lazy" decoding="async"></p>
<p><strong>SETP 2</strong>：登入後選擇上方的「行銷工具」，點擊「Pretty QR Code」。</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-03.webp" width="1400" height="510" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 3）" loading="lazy" decoding="async"></p>
<p><strong>SETP 3</strong>：上傳自己的背景圖片（建議是容易辨識的 Logo）。</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-04.webp" width="1400" height="1352" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 4）" loading="lazy" decoding="async"></p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-05.webp" width="1400" height="1232" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 5）" loading="lazy" decoding="async"></p>
<p><strong>SETP 4</strong>：調整目的連結，創建短網址。</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-06.webp" width="1400" height="533" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 6）" loading="lazy" decoding="async"></p>
<p><strong>SETP 5</strong>：點擊「產生 QR Code」，選擇要下載的格式就大功告成嚕！</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-07.webp" width="1400" height="1436" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 7）" loading="lazy" decoding="async"></p>
<p>下面就是筆者部落格的 QR Code～</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-08.webp" width="450" height="450" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 8）" loading="lazy" decoding="async"></p>
<p>如果想進一步知道這個 QR Code 被多少人訪問過，可以在「短網址」的分頁中看到。</p>
<p><img src="https://deanlin.net/blog/202305/pretty-qr-code/images/pretty-qr-code-09.webp" width="1400" height="1005" alt="使用 Pretty QR Code 讓你擁有高質感的 QR Code（圖 9）" loading="lazy" decoding="async"></p>
<p>以上就是 lihi Pretty QR Code 的使用分享，希望有幫到想製作漂亮 QR Code 來行銷自己的朋友～</p>
<p>上面的只是 lihi 短網址的基礎服務，更多細節請參考官網：<a href="https://lihi.io/">https://lihi.io/</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>透過實際案例了解在 Vuetify（Vite &amp; TypeScript）中如何運用 @vue/test-utils</title>
      <link>https://deanlin.net/blog/202301/vue-test-utils-examples/</link>
      <pubDate>Thu, 19 Jan 2023 13:58:02 +0800</pubDate>
      <guid>https://deanlin.net/blog/202301/vue-test-utils-examples/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>今天這篇文章，筆者會用一個簡易的「登入頁面」，來介紹 @vue/test-utils 這款套件的常用功能（ex：getComponent、setValue、wrapper.vm、trigger button、Vue Router）</description>
      <media:content url="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-og_hu_be420e200f2d4408.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><a href="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/">上一篇文章中</a>，我們成功在 Vuetify（Vite &amp; TypeScript）中導入單元測試 Unit Testing。</p>
<p>今天這篇文章，筆者會用一個簡易的「登入頁面」，來介紹 @vue/test-utils 這款套件的常用功能：</p>
<ul>
<li>用 <strong>get</strong> 找出元素（element）</li>
<li>用 <strong>getComponent</strong> 找出元件（component）</li>
<li>用 <strong>setValue</strong> 模擬使用者對元件（component）輸入值</li>
<li>透過 <strong>wrapper.vm</strong> 取得 data 資訊</li>
<li>取得元素（element）的 <strong>attributes</strong> 屬性</li>
<li>模擬使用者點擊輸入框（<strong>focus</strong>）輸入文字，然後離開（<strong>blur</strong>）的動作</li>
<li>當操作錯誤時，能捕捉預期的 <strong>error message</strong></li>
<li>確認使用者操作系統時，元件的 <strong>attributes</strong> 如預期改變（ex： disabled、type）</li>
<li>當元素採用 v-show 策略時，用 <strong>isVisible</strong> 進行判斷</li>
<li>模擬使用者 <strong>trigger button</strong> 事件</li>
<li>使用 <strong>Vue Router</strong> 時，頁面是否如預期跳轉</li>
</ul>
<p><img src="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-01.webp" width="1148" height="599" alt="透過實際案例了解在 Vuetify（Vite &amp; TypeScript）中如何運用 @vue/test-utils（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>大綱

ㄧ、從測試的角度來設計頁面規格
二、撰寫登入頁程式
三、從測試案例了解 @vue/test-utils 常用的功能
  ▶︎ 測試檔案初始設定
  ▶︎ 用 get 找出元素（element），並確認是有顯示在畫面上的（isVisible）
  ▶︎ 透過 getComponent 取出元件，並指定「input」來 setValue
  ▶︎ 在輸入框輸入後，用 wrapper.vm.xxx 確定 data 的有成功寫入
  ▶︎ 取得元素的 attributes，確認設定符合預期
  ▶︎ 模擬使用者點擊輸入框（focus）輸入文字，然後離開（blur）的動作
  ▶︎ 使用者操作系統時，元件的 attributes 如預期改變
  ▶︎ 模擬使用者 trigger button 事件，觀察頁面是否如預期跳轉（Vue Router）
</code></pre><blockquote>
<p><em>讀者可以直接到<a href="https://github.com/dean9703111/-vuetify-vite-ts-test-exmaples">筆者的 Github</a>，將專案 Clone 下來跑跑看，會更好理解裏面的邏輯（順手按個 ⭐ 更好 🤩）。</em></p>
</blockquote>
<h2 id="ㄧ從測試的角度來設計頁面規格">ㄧ、從測試的角度來設計頁面規格<a class="h-anchor" href="#%e3%84%a7%e5%be%9e%e6%b8%ac%e8%a9%a6%e7%9a%84%e8%a7%92%e5%ba%a6%e4%be%86%e8%a8%ad%e8%a8%88%e9%a0%81%e9%9d%a2%e8%a6%8f%e6%a0%bc" aria-label="這個段落的連結"></a></h2>
<p>在<a href="https://deanlin.net/blog/202301/frontend-testing-roadmap/">第一篇文章</a>有談到 TDD（Test-Driven Development），測試驅動開發。</p>
<p>假使我們要設計一個登入頁，可以訂製如下規格：</p>
<h3 id="-基礎元件功能">基礎元件功能<a class="h-anchor" href="#-%e5%9f%ba%e7%a4%8e%e5%85%83%e4%bb%b6%e5%8a%9f%e8%83%bd" aria-label="這個段落的連結"></a></h3>
<ul>
<li>確認 email、password、LoginBtn 元素存在</li>
<li>確認 Email 輸入框可以填資料</li>
<li>確認 Password 輸入框可以填資料</li>
<li>Email、Password 輸入後，data 對應的 formData 有存入相應的值</li>
<li>預設 Password 輸入框的 type 為 password，不直接顯示</li>
</ul>
<h3 id="-測試錯誤的-emailpassword-情境">測試錯誤的 Email、Password 情境<a class="h-anchor" href="#-%e6%b8%ac%e8%a9%a6%e9%8c%af%e8%aa%a4%e7%9a%84-emailpassword-%e6%83%85%e5%a2%83" aria-label="這個段落的連結"></a></h3>
<ul>
<li>當 Email 沒有填寫時，跳出 Required 的錯誤訊息</li>
<li>當 Email 格式錯誤時，跳出相關錯誤訊息</li>
<li>當 Password 沒有填寫時，跳出 Required 的錯誤訊息</li>
<li>當 Password 字數不滿 8 位時，跳出相關錯誤訊息</li>
</ul>
<h3 id="-會讓元件屬性改變的行為">會讓元件屬性改變的行為<a class="h-anchor" href="#-%e6%9c%83%e8%ae%93%e5%85%83%e4%bb%b6%e5%b1%ac%e6%80%a7%e6%94%b9%e8%ae%8a%e7%9a%84%e8%a1%8c%e7%82%ba" aria-label="這個段落的連結"></a></h3>
<ul>
<li>當 Email、Password 未填寫時，登入按鈕為 Disabled</li>
<li>當 Email、Password 格式錯誤時，登入按鈕為 Disabled</li>
<li>當 Email、Password 格式正確時，登入按鈕可點擊</li>
<li>點擊顯示 Password 的 icon 時，type 會轉換成 text</li>
</ul>
<h3 id="-登入功能">登入功能<a class="h-anchor" href="#-%e7%99%bb%e5%85%a5%e5%8a%9f%e8%83%bd" aria-label="這個段落的連結"></a></h3>
<ul>
<li>輸入錯誤的帳號密碼，會彈出警視窗</li>
<li>確認 Router 可以順利導向</li>
</ul>
<blockquote>
<p>上面訂的規格，就是我們後續要測試的情境。</p>
</blockquote>
<hr>
<h2 id="二撰寫登入頁程式">二、撰寫登入頁程式<a class="h-anchor" href="#%e4%ba%8c%e6%92%b0%e5%af%ab%e7%99%bb%e5%85%a5%e9%a0%81%e7%a8%8b%e5%bc%8f" aria-label="這個段落的連結"></a></h2>
<p>在 views 資料夾下新增「Login.vue」來做登入頁，幾個重點說一下：</p>
<ul>
<li>使用 dataset（ex：data-test=”xxx”）來做標記，因為 id 或 class 通常都是因為其他用途而存在的，要是 coding 中不小心動到 id 或是 class 會造成測試不通過</li>
<li>在 v-text-field 有設計「rules」來做校驗</li>
<li>「valid」影響輸入按鈕是否能按，「pwshow」是密碼是否顯示，「loginFail」決定登入失敗的錯誤訊息是否顯示</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">template</span>&gt;
</span></span><span style="display:flex;"><span>  &lt;<span style="color:#7ee787">div</span> class<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;d-flex align-center flex-column&#34;</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">v-card</span> width<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;400&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">v-container</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;<span style="color:#7ee787">v-form</span> v-model<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;valid&#34;</span> lazy-validation&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">v-alert</span>
</span></span><span style="display:flex;"><span>            v-show<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;loginFail&#34;</span>
</span></span><span style="display:flex;"><span>            type<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;error&#34;</span>
</span></span><span style="display:flex;"><span>            class<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;mb-4&#34;</span>
</span></span><span style="display:flex;"><span>            data-test<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;loginAlert&#34;</span>
</span></span><span style="display:flex;"><span>          &gt;
</span></span><span style="display:flex;"><span>            You are entering an incorrect email and/or password.
</span></span><span style="display:flex;"><span>          &lt;/<span style="color:#7ee787">v-alert</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">v-text-field</span>
</span></span><span style="display:flex;"><span>            v-model<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;formData.email&#34;</span>
</span></span><span style="display:flex;"><span>            :rules<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;emailRules&#34;</span>
</span></span><span style="display:flex;"><span>            label<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;Email&#34;</span>
</span></span><span style="display:flex;"><span>            required
</span></span><span style="display:flex;"><span>            data-test<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;email&#34;</span>
</span></span><span style="display:flex;"><span>          &gt;&lt;/<span style="color:#7ee787">v-text-field</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">v-text-field</span>
</span></span><span style="display:flex;"><span>            v-model<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;formData.password&#34;</span>
</span></span><span style="display:flex;"><span>            :append-icon<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;pwShow ? &#39;mdi-eye&#39; : &#39;mdi-eye-off&#39;&#34;</span>
</span></span><span style="display:flex;"><span>            :rules<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;[rules.required, rules.min]&#34;</span>
</span></span><span style="display:flex;"><span>            :type<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;pwShow ? &#39;text&#39; : &#39;password&#39;&#34;</span>
</span></span><span style="display:flex;"><span>            name<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;input-10-1&#34;</span>
</span></span><span style="display:flex;"><span>            label<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;Password&#34;</span>
</span></span><span style="display:flex;"><span>            hint<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;At least 8 characters&#34;</span>
</span></span><span style="display:flex;"><span>            counter
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">@</span>click:append<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;pwShow = !pwShow&#34;</span>
</span></span><span style="display:flex;"><span>            data-test<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;password&#34;</span>
</span></span><span style="display:flex;"><span>          &gt;&lt;/<span style="color:#7ee787">v-text-field</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">v-spacer</span>&gt;&lt;/<span style="color:#7ee787">v-spacer</span>&gt;
</span></span><span style="display:flex;"><span>          &lt;<span style="color:#7ee787">v-btn</span>
</span></span><span style="display:flex;"><span>            x-large
</span></span><span style="display:flex;"><span>            block
</span></span><span style="display:flex;"><span>            :disabled<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;!valid&#34;</span>
</span></span><span style="display:flex;"><span>            color<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;success&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f85149">@</span>click<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;login&#34;</span>
</span></span><span style="display:flex;"><span>            data-test<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;loginBtn&#34;</span>
</span></span><span style="display:flex;"><span>          &gt;
</span></span><span style="display:flex;"><span>            Login
</span></span><span style="display:flex;"><span>          &lt;/<span style="color:#7ee787">v-btn</span>&gt;
</span></span><span style="display:flex;"><span>        &lt;/<span style="color:#7ee787">v-form</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;/<span style="color:#7ee787">v-container</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;/<span style="color:#7ee787">v-card</span>&gt;
</span></span><span style="display:flex;"><span>  &lt;/<span style="color:#7ee787">div</span>&gt;
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">template</span>&gt;
</span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">script</span> setup&gt;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { ref, reactive } from <span style="color:#a5d6ff">&#34;vue&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> router from <span style="color:#a5d6ff">&#34;@/router&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> valid <span style="color:#ff7b72;font-weight:bold">=</span> ref(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> pwShow <span style="color:#ff7b72;font-weight:bold">=</span> ref(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> loginFail <span style="color:#ff7b72;font-weight:bold">=</span> ref(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> formData <span style="color:#ff7b72;font-weight:bold">=</span> reactive({
</span></span><span style="display:flex;"><span>  email<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>  password<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> emailRules <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>  (v) =&gt; <span style="color:#ff7b72;font-weight:bold">!!</span>v <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#34;Required&#34;</span>,
</span></span><span style="display:flex;"><span>  (v) =&gt; <span style="color:#ff7b72;font-weight:bold">/</span>.<span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#f85149">@</span>.<span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#f85149">\</span>..<span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#f85149">/.test(v) || &#34;E-mail must be valid&#34;,</span>
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> rules <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  required<span style="color:#ff7b72;font-weight:bold">:</span> (value) =&gt; <span style="color:#ff7b72;font-weight:bold">!!</span>value <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#34;Required&#34;</span>,
</span></span><span style="display:flex;"><span>  min<span style="color:#ff7b72;font-weight:bold">:</span> (v) =&gt; (v <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> v.length <span style="color:#ff7b72;font-weight:bold">&gt;=</span> <span style="color:#a5d6ff">8</span>) <span style="color:#ff7b72;font-weight:bold">||</span> <span style="color:#a5d6ff">&#34;Min 8 characters&#34;</span>,
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> login <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 這邊只是為了方便理解，實際上應該要呼叫 api
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (
</span></span><span style="display:flex;"><span>    formData.email <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#34;baoboa@mail.com&#34;</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span>
</span></span><span style="display:flex;"><span>    formData.password <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#34;baobaocute&#34;</span>
</span></span><span style="display:flex;"><span>  ) {
</span></span><span style="display:flex;"><span>    router.push(<span style="color:#a5d6ff">&#34;/home&#34;</span>);
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">else</span> {
</span></span><span style="display:flex;"><span>    loginFail.value <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#79c0ff">true</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">script</span>&gt;
</span></span></code></pre></div><p>完成後在終端機輸入 <code>yarn dev</code>，然後進入網址：<a href="http://localhost:3000/">http://localhost:3000</a></p>
<p><img src="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-02.webp" width="1400" height="586" alt="二、撰寫登入頁程式（圖 2）" loading="lazy" decoding="async"></p>
<blockquote>
<p>我這邊有調整「src/router/index.ts」，將 Login 的 path 設定為「/」，讀者可以<a href="https://github.com/dean9703111/vuetify-vite-ts-test-exmaples/blob/master/src/router/index.ts">參考 Github</a>。</p>
</blockquote>
<hr>
<h2 id="三從測試案例了解-vuetest-utils-常用的功能">三、從測試案例了解 @vue/test-utils 常用的功能<a class="h-anchor" href="#%e4%b8%89%e5%be%9e%e6%b8%ac%e8%a9%a6%e6%a1%88%e4%be%8b%e4%ba%86%e8%a7%a3-vuetest-utils-%e5%b8%b8%e7%94%a8%e7%9a%84%e5%8a%9f%e8%83%bd" aria-label="這個段落的連結"></a></h2>
<p>這邊會取幾個測試案例來做解說，完成的範例請參考<a href="https://github.com/dean9703111/-vuetify-vite-ts-test-exmaples">筆者的 Github</a>（順手點個 Star 會讓筆者會開心一整天）。</p>
<h3 id="-測試檔案初始設定">測試檔案初始設定<a class="h-anchor" href="#-%e6%b8%ac%e8%a9%a6%e6%aa%94%e6%a1%88%e5%88%9d%e5%a7%8b%e8%a8%ad%e5%ae%9a" aria-label="這個段落的連結"></a></h3>
<p>我們在 views 資料夾下新增「test」資料夾，並新增「Login.spec.ts」的測試檔案：</p>
<ul>
<li>透過 import 引入我們會使用到的方法</li>
<li>這邊透過 beforeEach 來初始化我們要測試 Login.vue</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { describe, it, expect, beforeEach, vi } from <span style="color:#a5d6ff">&#34;vitest&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { mount, VueWrapper } from <span style="color:#a5d6ff">&#34;@vue/test-utils&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> Login from <span style="color:#a5d6ff">&#34;../Login.vue&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> vuetify from <span style="color:#a5d6ff">&#34;@/plugins/vuetify&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> router from <span style="color:#a5d6ff">&#34;@/router&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">let</span> wrapper<span style="color:#ff7b72;font-weight:bold">:</span> VueWrapper<span style="color:#ff7b72;font-weight:bold">&lt;</span>any<span style="color:#ff7b72;font-weight:bold">&gt;</span>;
</span></span><span style="display:flex;"><span>beforeEach(<span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  wrapper <span style="color:#ff7b72;font-weight:bold">=</span> mount(Login, {
</span></span><span style="display:flex;"><span>    global<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>      plugins<span style="color:#ff7b72;font-weight:bold">:</span> [vuetify, router],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-用-get-找出元素element並確認是有顯示在畫面上的isvisible">用 get 找出元素（element），並確認是有顯示在畫面上的（isVisible）<a class="h-anchor" href="#-%e7%94%a8-get-%e6%89%be%e5%87%ba%e5%85%83%e7%b4%a0element%e4%b8%a6%e7%a2%ba%e8%aa%8d%e6%98%af%e6%9c%89%e9%a1%af%e7%a4%ba%e5%9c%a8%e7%95%ab%e9%9d%a2%e4%b8%8a%e7%9a%84isvisible" aria-label="這個段落的連結"></a></h3>
<blockquote>
<p>find 也可以達到一樣的效果，但沒找到時並不會回傳錯誤訊息。</p>
</blockquote>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;確認 Email、Password、LoginBtn 元素存在&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  expect(wrapper.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>).isVisible()).toBe(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>  expect(wrapper.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#39;[data-test=&#34;password&#34;]&#39;</span>).isVisible()).toBe(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>  expect(wrapper.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#39;[data-test=&#34;loginBtn&#34;]&#39;</span>).isVisible()).toBe(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-透過-getcomponent-取出元件並指定input來-setvalue">透過 getComponent 取出元件，並指定「input」來 setValue<a class="h-anchor" href="#-%e9%80%8f%e9%81%8e-getcomponent-%e5%8f%96%e5%87%ba%e5%85%83%e4%bb%b6%e4%b8%a6%e6%8c%87%e5%ae%9ainput%e4%be%86-setvalue" aria-label="這個段落的連結"></a></h3>
<blockquote>
<p>因為 Vuetify 的 v-text-field 是 div 層，所以需要再透過 get 往下找到 input 這個元素。</p>
</blockquote>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;確認 Email 輸入框可以填資料&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> emailInput <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>).<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> emailInput.setValue(<span style="color:#a5d6ff">&#34;test@mail.com&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(emailInput.element.value).toBe(<span style="color:#a5d6ff">&#34;test@mail.com&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-在輸入框輸入後用-wrappervmxxx-確定-data-的有成功寫入">在輸入框輸入後，用 wrapper.vm.xxx 確定 data 的有成功寫入<a class="h-anchor" href="#-%e5%9c%a8%e8%bc%b8%e5%85%a5%e6%a1%86%e8%bc%b8%e5%85%a5%e5%be%8c%e7%94%a8-wrappervmxxx-%e7%a2%ba%e5%ae%9a-data-%e7%9a%84%e6%9c%89%e6%88%90%e5%8a%9f%e5%af%ab%e5%85%a5" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;Email、Password 輸入後，data 對應的 formData 有寫入相應的值&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> formData <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>    email<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;test@mail.com&#34;</span>,
</span></span><span style="display:flex;"><span>    password<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;qazwsx1234&#34;</span>,
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> emailInput <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>).<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> emailInput.setValue(formData.email);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> passwordInput <span style="color:#ff7b72;font-weight:bold">=</span> wrapper
</span></span><span style="display:flex;"><span>    .getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;password&#34;]&#39;</span>)
</span></span><span style="display:flex;"><span>    .<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> passwordInput.setValue(formData.password);
</span></span><span style="display:flex;"><span>  expect(wrapper.vm.formData).toEqual(formData);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-取得元素的-attributes確認設定符合預期">取得元素的 attributes，確認設定符合預期<a class="h-anchor" href="#-%e5%8f%96%e5%be%97%e5%85%83%e7%b4%a0%e7%9a%84-attributes%e7%a2%ba%e8%aa%8d%e8%a8%ad%e5%ae%9a%e7%ac%a6%e5%90%88%e9%a0%90%e6%9c%9f" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;預設 Password 輸入框的 type 為 password，不直接顯示&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> password <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;password&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  expect(password.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).attributes().<span style="color:#ff7b72">type</span>).toBe(<span style="color:#a5d6ff">&#34;password&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-模擬使用者點擊輸入框focus輸入文字然後離開blur的動作並確認操作錯誤時能捕捉預期的-error-message">模擬使用者點擊輸入框（focus）輸入文字，然後離開（blur）的動作；並確認操作錯誤時，能捕捉預期的 error message<a class="h-anchor" href="#-%e6%a8%a1%e6%93%ac%e4%bd%bf%e7%94%a8%e8%80%85%e9%bb%9e%e6%93%8a%e8%bc%b8%e5%85%a5%e6%a1%86focus%e8%bc%b8%e5%85%a5%e6%96%87%e5%ad%97%e7%84%b6%e5%be%8c%e9%9b%a2%e9%96%8bblur%e7%9a%84%e5%8b%95%e4%bd%9c%e4%b8%a6%e7%a2%ba%e8%aa%8d%e6%93%8d%e4%bd%9c%e9%8c%af%e8%aa%a4%e6%99%82%e8%83%bd%e6%8d%95%e6%8d%89%e9%a0%90%e6%9c%9f%e7%9a%84-error-message" aria-label="這個段落的連結"></a></h3>
<p><img src="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-03.webp" width="1226" height="766" alt="︎ 模擬使用者點擊輸入框（focus）輸入文字，然後離開（blur）的動作；並確認操作錯誤時，能捕捉預期的 error message（圖 3）" loading="lazy" decoding="async"></p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;當 Email 沒有填寫時，跳出 Required 的錯誤訊息&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> email <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;focus&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(<span style="color:#a5d6ff">&#34;123&#34;</span>); <span style="color:#8b949e;font-style:italic">//先隨便塞值
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(<span style="color:#a5d6ff">&#34;&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;blur&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> emailErrorMsg <span style="color:#ff7b72;font-weight:bold">=</span> email.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;.v-messages__message&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(emailErrorMsg.text()).toEqual(<span style="color:#a5d6ff">&#34;Required&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-使用者操作系統時元件的-attributes-如預期改變exemailpassword-填寫正確登入按鈕-disabled-移除">使用者操作系統時，元件的 attributes 如預期改變（ex：Email、Password 填寫正確，登入按鈕 Disabled 移除）<a class="h-anchor" href="#-%e4%bd%bf%e7%94%a8%e8%80%85%e6%93%8d%e4%bd%9c%e7%b3%bb%e7%b5%b1%e6%99%82%e5%85%83%e4%bb%b6%e7%9a%84-attributes-%e5%a6%82%e9%a0%90%e6%9c%9f%e6%94%b9%e8%ae%8aexemailpassword-%e5%a1%ab%e5%af%ab%e6%ad%a3%e7%a2%ba%e7%99%bb%e5%85%a5%e6%8c%89%e9%88%95-disabled-%e7%a7%bb%e9%99%a4" aria-label="這個段落的連結"></a></h3>
<figure class="figure figure-video">
  <video class="gifv" muted loop playsinline controls preload="none" poster="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-04.webp" width="626" height="348" aria-label="︎ 使用者操作系統時，元件的 attributes 如預期改變（ex：Email、Password 填寫正確，登入按鈕 Disabled 移除）（圖 4）" data-gifv>
    <source src="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-04.mp4" type="video/mp4">
  </video>
</figure>

<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;當 Email、Password 格式正確時，登入按鈕可點擊&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> loginBtn <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#39;[data-test=&#34;loginBtn&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  expect(loginBtn.attributes().disabled).toBeDefined();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> formData <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>    email<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;baoboa@mail.com&#34;</span>,
</span></span><span style="display:flex;"><span>    password<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;baobaocute&#34;</span>,
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> email <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;focus&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(formData.email);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;blur&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> password <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;password&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.trigger(<span style="color:#a5d6ff">&#34;focus&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(formData.password);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.trigger(<span style="color:#a5d6ff">&#34;blur&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  expect(loginBtn.attributes().disabled).toBeUndefined();
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><h3 id="-模擬使用者-trigger-button-事件觀察頁面是否如預期跳轉vue-router">模擬使用者 trigger button 事件，觀察頁面是否如預期跳轉（Vue Router）<a class="h-anchor" href="#-%e6%a8%a1%e6%93%ac%e4%bd%bf%e7%94%a8%e8%80%85-trigger-button-%e4%ba%8b%e4%bb%b6%e8%a7%80%e5%af%9f%e9%a0%81%e9%9d%a2%e6%98%af%e5%90%a6%e5%a6%82%e9%a0%90%e6%9c%9f%e8%b7%b3%e8%bd%89vue-router" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>it(<span style="color:#a5d6ff">&#34;確認 Router 可以順利導向&#34;</span>, <span style="color:#ff7b72">async</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> formData <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>    email<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;baoboa@mail.com&#34;</span>,
</span></span><span style="display:flex;"><span>    password<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;baobaocute&#34;</span>,
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> email <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;email&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;focus&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(formData.email);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> email.trigger(<span style="color:#a5d6ff">&#34;blur&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> password <span style="color:#ff7b72;font-weight:bold">=</span> wrapper.getComponent(<span style="color:#a5d6ff">&#39;[data-test=&#34;password&#34;]&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.trigger(<span style="color:#a5d6ff">&#34;focus&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#34;input&#34;</span>).setValue(formData.password);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> password.trigger(<span style="color:#a5d6ff">&#34;blur&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 使用真實的 Router 測試時，若想要捕捉 Push 的行為，須透過 vi.spyOn 來模擬 router.push
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> spyPush <span style="color:#ff7b72;font-weight:bold">=</span> vi.spyOn(router, <span style="color:#a5d6ff">&#34;push&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> wrapper.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">&#39;[data-test=&#34;loginBtn&#34;]&#39;</span>).trigger(<span style="color:#a5d6ff">&#34;click&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(spyPush).toHaveBeenCalledTimes(<span style="color:#a5d6ff">1</span>);
</span></span><span style="display:flex;"><span>  expect(spyPush).toHaveBeenCalledWith(<span style="color:#a5d6ff">&#34;/home&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>在文章的最後，筆者也放一下所有測試的結果圖（可透過套件 or 指令 <code>yarn test</code> 執行）：</p>
<p><img src="https://deanlin.net/blog/202301/vue-test-utils-examples/images/vue-test-utils-examples-05.webp" width="1400" height="1192" alt="︎ 模擬使用者 trigger button 事件，觀察頁面是否如預期跳轉（Vue Router）（圖 5）" loading="lazy" decoding="async"></p>
<p>今天的文章就分享到這邊，我們下次再見～</p>
<a class="linkcard" href="https://deanlin.net/series/frontend-testing/">
  <span class="lc-body">
    <span class="lc-title">前端單元測試</span>
    <span class="lc-desc">替前端專案導入測試：學習地圖、Jest 基礎與進階，再到 Vuetify（Vite &amp; TypeScript）中的 @vue/test-utils。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>在 Vuetify（Vite &amp; TypeScript）中導入單元測試 Unit Testing</title>
      <link>https://deanlin.net/blog/202301/vuetify-vite-unit-testing/</link>
      <pubDate>Thu, 19 Jan 2023 13:58:00 +0800</pubDate>
      <guid>https://deanlin.net/blog/202301/vuetify-vite-unit-testing/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>這篇文章會先帶大家建立 Vuetify 的初始環境（Vue3 & Vite & TypeScript），並導入「Vue Test Utils」這款測試工具。</description>
      <media:content url="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-og_hu_c3095805906bba3.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<blockquote>
<p>如果你完全沒有接觸過單元測試，建議可以先閱讀這兩篇文章：「<a href="https://deanlin.net/blog/202301/jest-unit-testing-typescript/">認識 Jest 測試框架，開始動手寫單元測試</a>、<a href="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/">Jest 框架進階應用（describe、Setup and Teardown、Mock function） </a>」。</p>
</blockquote>
<p>這篇文章會先帶大家建立 Vuetify 的初始環境（Vue3 &amp; Vite &amp; TypeScript），並導入「Vue Test Utils」這款測試工具。</p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-01.webp" width="1148" height="598" alt="在 Vuetify（Vite &amp; TypeScript）中導入單元測試 Unit Testing（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>大綱

ㄧ、初始化專案、安裝必要套件
二、編寫測試的 config 檔
三、撰寫第一隻測試程式
四、運行測試程式確認結果符合預期
五、VSCode 的 Vitest 外掛
六、一些建議
</code></pre><blockquote>
<p>讀者可以直接到<a href="https://github.com/dean9703111/vuetify-vite-ts-test">筆者的 Github </a>，將專案 Clone 下來跑跑看，會更好理解裏面的邏輯（順手按個 ⭐ 更好 🤩）。</p>
</blockquote>
<h2 id="ㄧ初始化專案安裝必要套件">ㄧ、初始化專案、安裝必要套件<a class="h-anchor" href="#%e3%84%a7%e5%88%9d%e5%a7%8b%e5%8c%96%e5%b0%88%e6%a1%88%e5%ae%89%e8%a3%9d%e5%bf%85%e8%a6%81%e5%a5%97%e4%bb%b6" aria-label="這個段落的連結"></a></h2>
<blockquote>
<p>筆者 node.js 的版本為 18.13.0，建議選擇較新的版本，否則安裝套件的過程會遇到一些問題。</p>
</blockquote>
<p>這邊我們參考 Vuetify 官方文件的指令 <code>yarn create vuetify</code> 來建立專案。</p>
<p>建立過程中會問你很多問題，筆者的選擇如下：</p>
<ul>
<li><strong>Project name</strong>：自行決定（ex：vuetify-vite-ts-test）</li>
<li><strong>Which preset would you like to install</strong>：Essentials (Vuetify, VueRouter, Pinia)</li>
<li><strong>Use TypeScript</strong>：Yes</li>
<li><strong>Would you like to install dependencies with yarn, npm, or pnpm?</strong>：Yarn</li>
</ul>
<blockquote>
<p>不過問題時常調整，大家參考就好。</p>
</blockquote>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-02.webp" width="1400" height="898" alt="ㄧ、初始化專案、安裝必要套件（圖 2）" loading="lazy" decoding="async"></p>
<p>建立好 Vuetify 專案後，我們來安裝測試輔助套件如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>yarn add --dev @vue/test-utils @vitejs/plugin-vue vitest jsdom
</span></span></code></pre></div><p>完成以上步驟後，在終端機輸入指令 <code>yarn dev</code>，看看是否能順利啟動。</p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-03.webp" width="489" height="195" alt="ㄧ、初始化專案、安裝必要套件（圖 3）" loading="lazy" decoding="async"></p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-04.webp" width="1400" height="795" alt="ㄧ、初始化專案、安裝必要套件（圖 4）" loading="lazy" decoding="async"></p>
<p>如果網頁能順利跑出來就恭喜你完成第一步嚕！</p>
<hr>
<h2 id="二編寫測試的-config-檔">二、編寫測試的 config 檔<a class="h-anchor" href="#%e4%ba%8c%e7%b7%a8%e5%af%ab%e6%b8%ac%e8%a9%a6%e7%9a%84-config-%e6%aa%94" aria-label="這個段落的連結"></a></h2>
<p>在專案根目錄下，新增 vitest.config.ts，這是專門為了單元測試寫的相關設定檔，你仔細看會發現它跟「vite.config.ts」設定很像。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { defineConfig } <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#39;vitest/config&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> vue <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#39;@vitejs/plugin-vue&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> vuetify <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#39;vite-plugin-vuetify&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { fileURLToPath, URL } <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#39;node:url&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">default</span> defineConfig({
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// Resolver
</span></span></span><span style="display:flex;"><span>  resolve<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// https://vitejs.dev/config/#resolve-alias
</span></span></span><span style="display:flex;"><span>    alias<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#a5d6ff">&#39;@&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> fileURLToPath(<span style="color:#ff7b72">new</span> URL(<span style="color:#a5d6ff">&#39;./src&#39;</span>, <span style="color:#ff7b72">import</span>.meta.url)),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// plugins
</span></span></span><span style="display:flex;"><span>  plugins<span style="color:#ff7b72;font-weight:bold">:</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// Vue3
</span></span></span><span style="display:flex;"><span>    vue(),
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// Vuetify Loader
</span></span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// https://github.com/vuetifyjs/vuetify-loader
</span></span></span><span style="display:flex;"><span>    vuetify({
</span></span><span style="display:flex;"><span>      autoImport: <span style="color:#ff7b72">true</span>,
</span></span><span style="display:flex;"><span>      styles<span style="color:#ff7b72;font-weight:bold">:</span> { configFile<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;./src/styles/variables.scss&#39;</span> },
</span></span><span style="display:flex;"><span>    }),
</span></span><span style="display:flex;"><span>  ],
</span></span><span style="display:flex;"><span>  test<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// https://vitest.dev/guide/#configuring-vitest
</span></span></span><span style="display:flex;"><span>    globals: <span style="color:#ff7b72">true</span>,
</span></span><span style="display:flex;"><span>    environment<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;jsdom&#39;</span>,
</span></span><span style="display:flex;"><span>    deps<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>      inline<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">&#39;vuetify&#39;</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="三撰寫第一隻測試程式">三、撰寫第一隻測試程式<a class="h-anchor" href="#%e4%b8%89%e6%92%b0%e5%af%ab%e7%ac%ac%e4%b8%80%e9%9a%bb%e6%b8%ac%e8%a9%a6%e7%a8%8b%e5%bc%8f" aria-label="這個段落的連結"></a></h2>
<p>我們就以入口頁「HelloWorld.vue」為範例跟大家說明。</p>
<h3 id="-調整srccomponentshelloworldvue的程式">調整「src/components/HelloWorld.vue」的程式<a class="h-anchor" href="#-%e8%aa%bf%e6%95%b4srccomponentshelloworldvue%e7%9a%84%e7%a8%8b%e5%bc%8f" aria-label="這個段落的連結"></a></h3>
<p>這裡我們新增 defineProps 以接收傳進來的「msg」，並在頁面上顯示。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>&lt;<span style="color:#7ee787">template</span>&gt;
</span></span><span style="display:flex;"><span>  &lt;<span style="color:#7ee787">v</span><span style="color:#f85149">-</span>container class<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;fill-height&#34;</span>&gt;
</span></span><span style="display:flex;"><span>    &lt;<span style="color:#7ee787">v</span><span style="color:#f85149">-</span>responsive class<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;d-flex align-center text-center fill-height&#34;</span>&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">v</span><span style="color:#f85149">-</span>img
</span></span><span style="display:flex;"><span>        contain
</span></span><span style="display:flex;"><span>        height<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;200&#34;</span>
</span></span><span style="display:flex;"><span>        src<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;@/assets/logo.svg&#34;</span>
</span></span><span style="display:flex;"><span>      /&gt;
</span></span><span style="display:flex;"><span>      &lt;<span style="color:#7ee787">h1</span> class<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;green&#34;</span>&gt;{{ msg }}&lt;/<span style="color:#7ee787">h1</span>&gt;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72;font-weight:bold">&lt;</span><span style="color:#f85149">/v-responsive&gt;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72;font-weight:bold">&lt;</span><span style="color:#f85149">/v-container&gt;</span>
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">template</span>&gt;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>&lt;<span style="color:#7ee787">script</span> setup lang<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;ts&#34;</span>&gt;
</span></span><span style="display:flex;"><span>defineProps<span style="color:#ff7b72;font-weight:bold">&lt;</span>{
</span></span><span style="display:flex;"><span>  msg: <span style="color:#ff7b72">string</span>;
</span></span><span style="display:flex;"><span>}<span style="color:#ff7b72;font-weight:bold">&gt;</span>();
</span></span><span style="display:flex;"><span>&lt;/<span style="color:#7ee787">script</span>&gt;
</span></span></code></pre></div><h3 id="-新增srccomponents__test__helloworldspects作為測試程式">新增「src/components/__test__/HelloWorld.spec.ts」作為測試程式<a class="h-anchor" href="#-%e6%96%b0%e5%a2%9esrccomponents__test__helloworldspects%e4%bd%9c%e7%82%ba%e6%b8%ac%e8%a9%a6%e7%a8%8b%e5%bc%8f" aria-label="這個段落的連結"></a></h3>
<p>在 components 資料夾下建立「__test__」的資料夾放置測試程式，我們透過它來確認 HelloWorld 頁面是否有收到 props 的 msg，並如預期被渲染。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { describe, it, expect } <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;vitest&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { mount } <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;@vue/test-utils&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> HelloWorld <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;../HelloWorld.vue&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> vuetify <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#39;@/plugins/vuetify&#39;</span>;
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;HelloWorld&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  it(<span style="color:#a5d6ff">&#34;renders properly&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> wrapper <span style="color:#ff7b72;font-weight:bold">=</span> mount(HelloWorld, {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">global</span><span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>        plugins<span style="color:#ff7b72;font-weight:bold">:</span> [vuetify],
</span></span><span style="display:flex;"><span>      },
</span></span><span style="display:flex;"><span>      props<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>        msg<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;Hello Vitest&#34;</span>
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>    expect(wrapper.text()).toContain(<span style="color:#a5d6ff">&#34;Hello Vitest&#34;</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><hr>
<h2 id="四運行測試程式確認結果符合預期">四、運行測試程式確認結果符合預期<a class="h-anchor" href="#%e5%9b%9b%e9%81%8b%e8%a1%8c%e6%b8%ac%e8%a9%a6%e7%a8%8b%e5%bc%8f%e7%a2%ba%e8%aa%8d%e7%b5%90%e6%9e%9c%e7%ac%a6%e5%90%88%e9%a0%90%e6%9c%9f" aria-label="這個段落的連結"></a></h2>
<p>在 package.json 新增一條測試用的 script：<code>&ldquo;test:unit&rdquo;: &ldquo;vitest &ndash;environment jsdom &ndash;root src/&quot;</code></p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;name&#34;</span>: <span style="color:#a5d6ff">&#34;vuetify-vite-ts-test&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;version&#34;</span>: <span style="color:#a5d6ff">&#34;0.0.0&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;private&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;scripts&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;dev&#34;</span>: <span style="color:#a5d6ff">&#34;vite&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;build&#34;</span>: <span style="color:#a5d6ff">&#34;vue-tsc --noEmit &amp;&amp; vite build&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;preview&#34;</span>: <span style="color:#a5d6ff">&#34;vite preview&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;lint&#34;</span>: <span style="color:#a5d6ff">&#34;eslint . --fix --ignore-path .gitignore&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;test:unit&#34;</span>: <span style="color:#a5d6ff">&#34;vitest --environment jsdom --root src/&#34;</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;dependencies&#34;</span>: {<span style="color:#f85149">...</span>},
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;devDependencies&#34;</span>: {<span style="color:#f85149">...</span>}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>新增後在終端機輸入指令 <code>yarn test:unit</code> 就會開始執行測試，如果看到下面的畫面，就恭喜你成功導入單元測試嚕！</p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-05.webp" width="944" height="420" alt="四、運行測試程式確認結果符合預期（圖 5）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="五vscode-的-vitest-外掛">五、VSCode 的 Vitest 外掛<a class="h-anchor" href="#%e4%ba%94vscode-%e7%9a%84-vitest-%e5%a4%96%e6%8e%9b" aria-label="這個段落的連結"></a></h2>
<p>跟 Jest 一樣，VSCode 也有 Vite 的外掛喔！他叫做 <a href="https://marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer">Vitest</a>。</p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-06.webp" width="1400" height="384" alt="五、VSCode 的 Vitest 外掛（圖 6）" loading="lazy" decoding="async"></p>
<p>安裝完成後，點擊 VSCode 左側的「Testing」icon，就可以測試嚕！</p>
<p><img src="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/images/vuetify-vite-unit-testing-07.webp" width="1400" height="540" alt="五、VSCode 的 Vitest 外掛（圖 7）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="六一些建議">六、一些建議<a class="h-anchor" href="#%e5%85%ad%e4%b8%80%e4%ba%9b%e5%bb%ba%e8%ad%b0" aria-label="這個段落的連結"></a></h2>
<h3 id="-vscode-改使用-volar-這個外掛">VSCode 改使用 Volar 這個外掛<a class="h-anchor" href="#-vscode-%e6%94%b9%e4%bd%bf%e7%94%a8-volar-%e9%80%99%e5%80%8b%e5%a4%96%e6%8e%9b" aria-label="這個段落的連結"></a></h3>
<p>今天這個是以 Vue3 設計的專案，如果你過去 VSCode 使用的版本是 Vue2，那可能有些程式下方會有警告，那可能是 VSCode 過去在 Vue2 時選用 <a href="https://marketplace.visualstudio.com/items?itemName=octref.vetur">Vetur</a> 這個外掛，現在 Vue3 要改成使用 <a href="https://marketplace.visualstudio.com/items?itemName=vue.volar">Volar</a>。</p>
<h3 id="-如果遇到了type-plugin-is-not-assignable-to-type-pluginoption這類的錯誤">如果遇到了「Type &lsquo;Plugin&rsquo; is not assignable to type &lsquo;PluginOption&rsquo;」這類的錯誤<a class="h-anchor" href="#-%e5%a6%82%e6%9e%9c%e9%81%87%e5%88%b0%e4%ba%86type-plugin-is-not-assignable-to-type-pluginoption%e9%80%99%e9%a1%9e%e7%9a%84%e9%8c%af%e8%aa%a4" aria-label="這個段落的連結"></a></h3>
<p>通常是因為安裝了多個 Vite 的版本所導致的，建議先將根目錄下的「node_modules」以及「.lock」全部移除，然後再重新安裝套件。</p>
<blockquote>
<p>筆者是在 nvm 切換 node.js 版本時遇到這個問題的。</p>
</blockquote>
<p>那今天的文章就分享到這裡，<a href="https://deanlin.net/blog/202301/vue-test-utils-examples/">下一篇文章會以實際案例跟大家分享 Vue Test Utils 的應用</a>！</p>
<a class="linkcard" href="https://deanlin.net/series/frontend-testing/">
  <span class="lc-body">
    <span class="lc-title">前端單元測試</span>
    <span class="lc-desc">替前端專案導入測試：學習地圖、Jest 基礎與進階，再到 Vuetify（Vite &amp; TypeScript）中的 @vue/test-utils。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>Jest 框架進階應用（describe、Setup and Teardown、Mock function）</title>
      <link>https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/</link>
      <pubDate>Thu, 19 Jan 2023 10:55:58 +0800</pubDate>
      <guid>https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>一個功能底下可能有很多函式，因此我們要對它進行分組（describe）；當我們想測試特定情境時，可以在測試前增加資料（Setup）並於測試後移除（Teardown）；為了讓單元測試更存粹，可以透過模擬函式（Mock function）減少相依性。</description>
      <media:content url="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-og_hu_8dc9a72b055a1c4f.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<blockquote>
<p><em>如</em>果你對 Jest 這個測試框架還一無所知，建議先看<a href="https://deanlin.net/blog/202301/jest-unit-testing-typescript/">上一篇文章</a>喔！</p>
</blockquote>
<p>一個功能底下可能有很多函式，如果測試程式沒有適當的區分，那在遇到錯誤時會難以管理，因此我們要對它進行分組（describe）；當我們想測試特定情境時，可以在測試前增加資料（Setup）並於測試後移除（Teardown）；為了讓單元測試更存粹，可以透過模擬函式（Mock function）減少相依性。</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-01.webp" width="1150" height="597" alt="Jest 框架進階應用（describe、Setup and Teardown、Mock function）（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>大綱

一、測試分組（describe）
二、在測試前、後要執行的任務（Setup and Teardown）
三、用 Mock Function 減少測試時的相依性
四、VSCode 的 Jest 外掛
</code></pre><blockquote>
<p>讀者可以直接到<a href="https://github.com/dean9703111/jest-typescript-starter/tree/master/jest-advanced">筆者的 Github </a>，將專案 Clone 下來跑跑看，會更好理解裏面的邏輯（順手按個 ⭐ 更好 🤩）。</p>
</blockquote>
<h2 id="一測試分組describe">一、測試分組（describe）<a class="h-anchor" href="#%e4%b8%80%e6%b8%ac%e8%a9%a6%e5%88%86%e7%b5%84describe" aria-label="這個段落的連結"></a></h2>
<p>比如今天我們設計一個計算機，裏面包含「加減乘除」等功能，如果要針對不同 function 做分組測試，我們可以這樣做：</p>
<ul>
<li><strong>calculator.ts</strong>：計算機基礎功能（加減乘除）。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> sum(a: <span style="color:#ff7b72">number</span>, b: <span style="color:#ff7b72">number</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> a <span style="color:#ff7b72;font-weight:bold">+</span> b;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> minus(a: <span style="color:#ff7b72">number</span>, b: <span style="color:#ff7b72">number</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> a <span style="color:#ff7b72;font-weight:bold">-</span> b;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> multiply(a: <span style="color:#ff7b72">number</span>, b: <span style="color:#ff7b72">number</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> a <span style="color:#ff7b72;font-weight:bold">*</span> b;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> divide(a: <span style="color:#ff7b72">number</span>, b: <span style="color:#ff7b72">number</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> a <span style="color:#ff7b72;font-weight:bold">/</span> b;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ul>
<li><strong>calculator.test.ts</strong>：分組測試計算機基礎功能。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { sum, minus, multiply, divide } <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;./calculator&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;測試加法&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;1 + 1 = 2&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(sum(<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">1</span>)).toBe(<span style="color:#a5d6ff">2</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;3 + 3 = 7&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(sum(<span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">4</span>)).toBe(<span style="color:#a5d6ff">7</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;測試減法&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;5 - 1 = 4&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(minus(<span style="color:#a5d6ff">5</span>, <span style="color:#a5d6ff">1</span>)).toBe(<span style="color:#a5d6ff">4</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;3 - 3 = 0&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(minus(<span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">3</span>)).toBe(<span style="color:#a5d6ff">0</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;測試乘法&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;1 * 5 = 5&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(multiply(<span style="color:#a5d6ff">1</span>, <span style="color:#a5d6ff">5</span>)).toBe(<span style="color:#a5d6ff">5</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;3 * 3 = 9&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(multiply(<span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">3</span>)).toBe(<span style="color:#a5d6ff">9</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;測試除法&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;6 / 2 = 3&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(divide(<span style="color:#a5d6ff">6</span>, <span style="color:#a5d6ff">2</span>)).toBe(<span style="color:#a5d6ff">3</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;3 / 2 = 1.5&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(divide(<span style="color:#a5d6ff">3</span>, <span style="color:#a5d6ff">2</span>)).toBe(<span style="color:#a5d6ff">1.5</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>運行測試指令 <code>npm run test</code>，順利的話就會看到下圖測試分組的結果嚕！</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-02.webp" width="946" height="1042" alt="一、測試分組（describe）（圖 2）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="二在測試前後要執行的任務setup-and-teardown">二、在測試前、後要執行的任務（Setup and Teardown）<a class="h-anchor" href="#%e4%ba%8c%e5%9c%a8%e6%b8%ac%e8%a9%a6%e5%89%8d%e5%be%8c%e8%a6%81%e5%9f%b7%e8%a1%8c%e7%9a%84%e4%bb%bb%e5%8b%99setup-and-teardown" aria-label="這個段落的連結"></a></h2>
<p>Jest 有提供 4 種函數：</p>
<ul>
<li><strong>beforeAll</strong>：在所有測試開始前執行。</li>
<li><strong>afterAll</strong>：在所有測試完畢後執行。</li>
<li><strong>beforeEach</strong>：在每個測試開始前執行。</li>
<li><strong>afterEach</strong>：在每個測試結束後執行。</li>
</ul>
<p>舉個例子，如果今天要設計「考核系統」，除了有考生外，還需要模擬出「合格」、「不合格」的情境，為了避免影響到資料庫，我們可以這樣做：</p>
<ul>
<li><strong>user.ts</strong>：我們先預設的 score 的基準為 60 分，可透過 addScore、minusScore 來加減分。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">default</span> <span style="color:#ff7b72">class</span> user {
</span></span><span style="display:flex;"><span>  name: <span style="color:#ff7b72">string</span>;
</span></span><span style="display:flex;"><span>  score: <span style="color:#ff7b72">number</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">constructor</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.name <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.score <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">60</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  addScore() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.score <span style="color:#ff7b72;font-weight:bold">+=</span> <span style="color:#a5d6ff">10</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  minusScore() {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.score <span style="color:#ff7b72;font-weight:bold">-=</span> <span style="color:#a5d6ff">10</span>;
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ul>
<li><strong>exam.test.ts</strong>：利用「beforeAll」將考生的資料初始化，並透過「beforeEach」將分組測試的分數重新校正（若少了這個動作，第二個測試會沿用上一個測試的結果，會變成 70–10=60 分），測試完畢後用「afterAll」將考生資料清空。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> user <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;./user&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> userDB: <span style="color:#ff7b72">user</span>[] <span style="color:#ff7b72;font-weight:bold">=</span> [];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> insertTestData <span style="color:#ff7b72;font-weight:bold">=</span> (name: <span style="color:#ff7b72">string</span>) <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> User <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> user();
</span></span><span style="display:flex;"><span>  User.name <span style="color:#ff7b72;font-weight:bold">=</span> name;
</span></span><span style="display:flex;"><span>  userDB.push(User);
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> clearTestData <span style="color:#ff7b72;font-weight:bold">=</span> () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  userDB.length <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>;
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>beforeAll(() <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  insertTestData(<span style="color:#a5d6ff">&#34;baobao&#34;</span>);
</span></span><span style="display:flex;"><span>  insertTestData(<span style="color:#a5d6ff">&#34;mochi&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;baoboa 分數計算&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  beforeEach(() <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">0</span>].score <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">60</span>;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;60 + 10 為 70 分&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">0</span>].addScore();
</span></span><span style="display:flex;"><span>    expect(userDB[<span style="color:#a5d6ff">0</span>].score).toBe(<span style="color:#a5d6ff">70</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;60 - 10 為 50 分&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">0</span>].minusScore();
</span></span><span style="display:flex;"><span>    expect(userDB[<span style="color:#a5d6ff">0</span>].score).toBe(<span style="color:#a5d6ff">50</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>describe(<span style="color:#a5d6ff">&#34;mochi 分數計算&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  beforeEach(() <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">1</span>].score <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">60</span>;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;60 + 10 為 70 分&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">1</span>].addScore();
</span></span><span style="display:flex;"><span>    expect(userDB[<span style="color:#a5d6ff">1</span>].score).toBe(<span style="color:#a5d6ff">70</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  test(<span style="color:#a5d6ff">&#34;60 - 10 為 50 分&#34;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    userDB[<span style="color:#a5d6ff">1</span>].minusScore();
</span></span><span style="display:flex;"><span>    expect(userDB[<span style="color:#a5d6ff">1</span>].score).toBe(<span style="color:#a5d6ff">50</span>);
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>afterAll(() <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>  clearTestData();
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>這邊補充說明一下，運行單個測試檔案的指令為： <code>npm run test -t path/user.test.ts</code>。</p>
<p>如果運行順利，就會看到下圖的測試結果嚕！</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-03.webp" width="1232" height="624" alt="二、在測試前、後要執行的任務（Setup and Teardown）（圖 3）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="三用-mock-function-減少測試時的相依性">三、用 Mock Function 減少測試時的相依性<a class="h-anchor" href="#%e4%b8%89%e7%94%a8-mock-function-%e6%b8%9b%e5%b0%91%e6%b8%ac%e8%a9%a6%e6%99%82%e7%9a%84%e7%9b%b8%e4%be%9d%e6%80%a7" aria-label="這個段落的連結"></a></h2>
<p>單元測試時，我們更關心「單一功能」的正確與否；如果一個單元測試涵蓋太多函式，那在發生錯誤時，我們很難判斷到底是哪一段發生錯誤。</p>
<ul>
<li><strong>fetch.js</strong>：我們透過「fetchData」呼叫外部 api，而「hideComplete」是設計用來隱藏已完成的 todos。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> axios <span style="color:#ff7b72">from</span> <span style="color:#a5d6ff">&#34;axios&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> fetchData() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> response <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> axios.<span style="color:#ff7b72">get</span>(<span style="color:#a5d6ff">`https://jsonplaceholder.typicode.com/todos`</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> response;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> hideComplete(data) {
</span></span><span style="display:flex;"><span>  data <span style="color:#ff7b72;font-weight:bold">=</span> data.filter(<span style="color:#ff7b72">function</span> (value<span style="color:#ff7b72;font-weight:bold">:</span> { completed: <span style="color:#ff7b72">boolean</span> }) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> value.completed <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#79c0ff">false</span>;
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> data;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ul>
<li><strong>fetch.test.js</strong>：為了減少相依性，我們不希望直接呼叫真實的 api，所以透過 <code>jest.mock</code> 將 axios 給 mock 起來；並透過 3 種情境測試：「有成功換成模擬資料、資料格式符合預期、獨立測試 hideComplete 函式」。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">import</span> { fetchData, hideComplete } from <span style="color:#a5d6ff">&#34;./fetch&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">import</span> axios from <span style="color:#a5d6ff">&#34;axios&#34;</span>;
</span></span><span style="display:flex;"><span>jest.mock(<span style="color:#a5d6ff">&#34;axios&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> mockedAxios <span style="color:#ff7b72;font-weight:bold">=</span> axios as jest.Mocked<span style="color:#ff7b72;font-weight:bold">&lt;</span><span style="color:#ff7b72">typeof</span> axios<span style="color:#ff7b72;font-weight:bold">&gt;</span>;
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 模擬我們期望收到的資料結構
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    userId<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1</span>,
</span></span><span style="display:flex;"><span>    id<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">1</span>,
</span></span><span style="display:flex;"><span>    title<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;task 1&#34;</span>,
</span></span><span style="display:flex;"><span>    completed<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">false</span>,
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    userId<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">2</span>,
</span></span><span style="display:flex;"><span>    id<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">2</span>,
</span></span><span style="display:flex;"><span>    title<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;task 2&#34;</span>,
</span></span><span style="display:flex;"><span>    completed<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#34;確認結果被替換成模擬資料&#34;</span>, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  mockedAxios.get.mockResolvedValue(data);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> result <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchData();
</span></span><span style="display:flex;"><span>  expect(result).toEqual(data);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#34;驗證資料格式&#34;</span>, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  mockedAxios.get.mockResolvedValue(data);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> result <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchData();
</span></span><span style="display:flex;"><span>  expect(<span style="color:#ff7b72">typeof</span> result[<span style="color:#a5d6ff">0</span>].userId).toBe(<span style="color:#a5d6ff">&#34;number&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(<span style="color:#ff7b72">typeof</span> result[<span style="color:#a5d6ff">0</span>].id).toBe(<span style="color:#a5d6ff">&#34;number&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(<span style="color:#ff7b72">typeof</span> result[<span style="color:#a5d6ff">0</span>].title).toBe(<span style="color:#a5d6ff">&#34;string&#34;</span>);
</span></span><span style="display:flex;"><span>  expect(<span style="color:#ff7b72">typeof</span> result[<span style="color:#a5d6ff">0</span>].completed).toBe(<span style="color:#a5d6ff">&#34;boolean&#34;</span>);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#34;測試 hideComplete 函式運作&#34;</span>, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  mockedAxios.get.mockResolvedValue(data);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">let</span> result <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchData();
</span></span><span style="display:flex;"><span>  result <span style="color:#ff7b72;font-weight:bold">=</span> hideComplete(result);
</span></span><span style="display:flex;"><span>  expect(result[<span style="color:#a5d6ff">0</span>].completed).toBe(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>輸入<code>npm run test -t path/user.test.ts</code>後，如果順利就會看到下圖的測試結果嚕！</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-04.webp" width="1256" height="616" alt="三、用 Mock Function 減少測試時的相依性（圖 4）" loading="lazy" decoding="async"></p>
<hr>
<h2 id="四vscode-的-jest-外掛">四、VSCode 的 Jest 外掛<a class="h-anchor" href="#%e5%9b%9bvscode-%e7%9a%84-jest-%e5%a4%96%e6%8e%9b" aria-label="這個段落的連結"></a></h2>
<p>每新增一個測試就要跑一次 <code>npm run test</code> 是不是很煩？如果想獨立測試某個檔案還要先確認文件路徑真的讓人心累 😨</p>
<p>但好在 VSCode 有「<a href="https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest">Jest</a>」這個 Extensions 讓我們事半功倍。</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-05.webp" width="1400" height="480" alt="四、VSCode 的 Jest 外掛（圖 5）" loading="lazy" decoding="async"></p>
<p>安裝完成後，VSCode 的左側會多出一個「Testing」的 icon，只要你在專案下有設定 <code>jest.config.js</code> 就會出現（如果還未設定可以參考<a href="https://deanlin.net/blog/202301/jest-unit-testing-typescript/">上一篇文章</a>）。</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-06.webp" width="1400" height="615" alt="四、VSCode 的 Jest 外掛（圖 6）" loading="lazy" decoding="async"></p>
<p>在 test 文檔左側即可看到測試成功的「v」與失敗的「x」。</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-07.webp" width="1048" height="462" alt="四、VSCode 的 Jest 外掛（圖 7）" loading="lazy" decoding="async"></p>
<p>失敗的測試會在 expect 下方出現紅色底線，滑鼠移過去就會顯示預期值（Expected）與接收值（Received）的差異。</p>
<p><img src="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/images/jest-advanced-mock-setup-teardown-08.webp" width="1400" height="641" alt="四、VSCode 的 Jest 外掛（圖 8）" loading="lazy" decoding="async"></p>
<blockquote>
<p><strong>備註</strong>：身邊有朋友在安裝外掛後遇到了一些奇怪的 Error 無法正常使用，重開 VSCode、看官方文檔怎麼調整都無用，最後用大絕「重新開機」就正常了</p>
</blockquote>
<hr>
<p>常用的 Jest 應用就介紹到這邊，真的要自己動手實驗過才會理解裏面的原理喔！</p>
<a class="linkcard" href="https://deanlin.net/series/frontend-testing/">
  <span class="lc-body">
    <span class="lc-title">前端單元測試</span>
    <span class="lc-desc">替前端專案導入測試：學習地圖、Jest 基礎與進階，再到 Vuetify（Vite &amp; TypeScript）中的 @vue/test-utils。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>認識 Jest 測試框架，開始動手寫單元測試（TypeScript）</title>
      <link>https://deanlin.net/blog/202301/jest-unit-testing-typescript/</link>
      <pubDate>Thu, 19 Jan 2023 10:55:37 +0800</pubDate>
      <guid>https://deanlin.net/blog/202301/jest-unit-testing-typescript/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>這篇文章會從建立專案開始，依序帶你了解需要設定的環境、單元測試的結構，接著帶你完成第一個單元測試。 在對單元測試有基礎的概念後，會透過實際的範例讓你了解不同匹配器（Matchers）的使用時機，話不多說，我們直接開始吧！</description>
      <media:content url="https://deanlin.net/blog/202301/jest-unit-testing-typescript/images/jest-unit-testing-typescript-og_hu_b8982ad14b0c0dbf.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<blockquote>
<p>如果你還在猶豫是否學習測試框架，不妨先看看筆者在<a href="https://deanlin.net/blog/202301/frontend-testing-roadmap/">上一篇文章</a>的分享喔！</p>
</blockquote>
<p>這篇文章會從建立專案開始，依序帶你了解需要設定的環境、單元測試的結構，接著帶你完成第一個單元測試。</p>
<p>在對單元測試有基礎的概念後，會透過實際的範例讓你了解不同匹配器（Matchers）的使用時機，話不多說，我們直接開始吧！</p>
<p><img src="https://deanlin.net/blog/202301/jest-unit-testing-typescript/images/jest-unit-testing-typescript-01.webp" width="1147" height="599" alt="認識 Jest 測試框架，開始動手寫單元測試（TypeScript）（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>大綱

一、初始化專案、設定測試環境
二、了解單元測試的結構，撰寫程式
三、了解 Jest 常用的匹配器（Matchers）
</code></pre><blockquote>
<p>讀者可以直接到<a href="https://github.com/dean9703111/jest-typescript-starter/tree/master/jest-basic">筆者的 Github</a> ，將專案 Clone 下來跑跑看，會更好理解裏面的邏輯（順手按個 ⭐ 更好 🤩）。</p>
</blockquote>
<h2 id="一初始化專案設定測試環境">一、初始化專案、設定測試環境<a class="h-anchor" href="#%e4%b8%80%e5%88%9d%e5%a7%8b%e5%8c%96%e5%b0%88%e6%a1%88%e8%a8%ad%e5%ae%9a%e6%b8%ac%e8%a9%a6%e7%92%b0%e5%a2%83" aria-label="這個段落的連結"></a></h2>
<p><strong>SETP 1</strong>：初始化專案＆安裝 Jest。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir jest-practice
</span></span><span style="display:flex;"><span>cd jest-practice
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>npm init -f
</span></span><span style="display:flex;"><span>npm install jest babel-jest ts-jest @types/jest
</span></span></code></pre></div><p><strong>SETP 2</strong>：進入「package.json」增加測試用 scripts。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f85149">...</span>
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;scripts&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;test&#34;</span>: <span style="color:#a5d6ff">&#34;jest&#34;</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f85149">...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>SETP 3</strong>：在根目錄下新增「jest.config.ts」。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>module.exports <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  transform<span style="color:#ff7b72;font-weight:bold">:</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a5d6ff">&#39;^.+\\.tsx?$&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;ts-jest&#39;</span>,
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  moduleFileExtensions<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">&#39;ts&#39;</span>],
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p><strong>SETP 4</strong>：在根目錄下新增「tsconfig.json」。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#7ee787">&#34;compilerOptions&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&#34;esModuleInterop&#34;</span>: <span style="color:#79c0ff">true</span>,
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><hr>
<h2 id="二了解單元測試的結構撰寫程式">二、了解單元測試的結構，撰寫程式<a class="h-anchor" href="#%e4%ba%8c%e4%ba%86%e8%a7%a3%e5%96%ae%e5%85%83%e6%b8%ac%e8%a9%a6%e7%9a%84%e7%b5%90%e6%a7%8b%e6%92%b0%e5%af%ab%e7%a8%8b%e5%bc%8f" aria-label="這個段落的連結"></a></h2>
<p>我們就用最簡單的「加法」來做說明：<code>a + b = c</code></p>
<p>如果把加法寫成單元測試，可以拆解成下面的結構：</p>
<ul>
<li><strong>測試的目標</strong>：我們要證明 <code>a + b = c</code>。</li>
<li><strong>要測試的 function</strong>：要測試 <code>sum</code> 這個 function。</li>
<li><strong>測試期待的結果</strong>：確認 sum 執行 <code>a + b</code> 的結果等於 <code>c</code>。</li>
</ul>
<p><strong>SETP 1</strong>：在專案下新增兩個檔案</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 放測試的 function</span>
</span></span><span style="display:flex;"><span>touch public.ts
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 測試的腳本</span>
</span></span><span style="display:flex;"><span>touch public.test.ts
</span></span></code></pre></div><p><strong>SETP 2</strong>：在 public.ts 撰寫 <code>sum</code> 這個 function，並於 public.test.ts 用測試程式來驗證他。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// public.ts
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">function</span> sum (a: <span style="color:#ff7b72">number</span> ,b: <span style="color:#ff7b72">number</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> a <span style="color:#ff7b72;font-weight:bold">+</span> b;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// public.test.ts
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { sum } <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">require</span>(<span style="color:#a5d6ff">&#39;./public&#39;</span>);
</span></span><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;測試 8 + 9 等於 17&#39;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(sum(<span style="color:#a5d6ff">8</span>, <span style="color:#a5d6ff">9</span>)).toBe(<span style="color:#a5d6ff">17</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p><strong>SETP 3</strong>：運行測試指令 <code>npm run test</code> ，如果順利就會看到下圖的結果：</p>
<p><img src="https://deanlin.net/blog/202301/jest-unit-testing-typescript/images/jest-unit-testing-typescript-02.webp" width="694" height="520" alt="二、了解單元測試的結構，撰寫程式（圖 2）" loading="lazy" decoding="async"></p>
<p>最後我們再來回顧一下先前提到的結構：</p>
<ul>
<li><strong>測試的目標</strong>：test(<code>測試 8 + 9 等於 17</code>, () =&gt; {});</li>
<li><strong>要測試的 function</strong>：<code>sum(8, 9)</code></li>
<li><strong>測試期待的結果</strong>：expect(sum(8, 9)).toBe(<code>17</code>);</li>
</ul>
<hr>
<h2 id="三了解-jest-常用的匹配器matchers">三、了解 Jest 常用的匹配器（Matchers）<a class="h-anchor" href="#%e4%b8%89%e4%ba%86%e8%a7%a3-jest-%e5%b8%b8%e7%94%a8%e7%9a%84%e5%8c%b9%e9%85%8d%e5%99%a8matchers" aria-label="這個段落的連結"></a></h2>
<p>匹配器（Matchers）是讓我們驗證執行結果是否符合預期的工具，下面筆者放上幾個常用的範例。</p>
<ul>
<li>toBe：最常用的匹配器。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;toBe 匹配器其實能取代許多 Matcher&#39;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    expect(<span style="color:#a5d6ff">1</span> <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">2</span>).toBe(<span style="color:#a5d6ff">3</span>);
</span></span><span style="display:flex;"><span>    expect(<span style="color:#79c0ff">null</span>).toBe(<span style="color:#79c0ff">null</span>);
</span></span><span style="display:flex;"><span>    expect(<span style="color:#79c0ff">null</span>).toBeNull();
</span></span><span style="display:flex;"><span>    expect(<span style="color:#79c0ff">undefined</span>).toBe(<span style="color:#79c0ff">undefined</span>);
</span></span><span style="display:flex;"><span>    expect(<span style="color:#79c0ff">undefined</span>).toBeUndefined();
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><ul>
<li>toEqual：如果要物件（object）間的比對可以使用它。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;toEqual 主要是比對物件&#39;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> { one: <span style="color:#ff7b72">1</span> };
</span></span><span style="display:flex;"><span>    data[<span style="color:#a5d6ff">&#39;two&#39;</span>] <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">2</span>;
</span></span><span style="display:flex;"><span>    expect(data).toEqual({ one: <span style="color:#ff7b72">1</span>, two: <span style="color:#ff7b72">2</span> });
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><ul>
<li>toBeCloseTo：應用在小數點的計算。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;小數點相加&#39;</span>, () <span style="color:#ff7b72;font-weight:bold">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> value <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0.1</span> <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">0.2</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// 有小數點的請使用 toBeCloseTo，否則會有 rounding error
</span></span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// expect(value).toBe(0.3);
</span></span></span><span style="display:flex;"><span>    expect(value).toBeCloseTo(<span style="color:#a5d6ff">0.3</span>); <span style="color:#8b949e;font-style:italic">// This works.
</span></span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><ul>
<li>toMatch：字串比對。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;透過 toMatch 確認「stop」是否存在於字串&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>    expect(<span style="color:#a5d6ff">&#39;Christoph&#39;</span>).toMatch(<span style="color:#79c0ff">/stop/</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><ul>
<li>toContain：確認「值」是否存在於陣列。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;toContain 會透過迭代來確認陣列是否存在某值&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> shoppingList <span style="color:#ff7b72;font-weight:bold">=</span> [
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#39;diapers&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#39;kleenex&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#39;trash bags&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#39;paper towels&#39;</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a5d6ff">&#39;milk&#39;</span>,
</span></span><span style="display:flex;"><span>    ];
</span></span><span style="display:flex;"><span>    expect(shoppingList).toContain(<span style="color:#a5d6ff">&#39;milk&#39;</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><ul>
<li>toThrow：確認吐出的錯誤訊息是否符合期待。</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>test(<span style="color:#a5d6ff">&#39;toThrow 可以用來檢核錯誤訊息&#39;</span>, () =&gt; {
</span></span><span style="display:flex;"><span>    expect(() =&gt; doSomething()).toThrow();
</span></span><span style="display:flex;"><span>    expect(() =&gt; doSomething()).toThrow(Error);
</span></span><span style="display:flex;"><span>    expect(() =&gt; doSomething()).toThrow(<span style="color:#a5d6ff">&#39;Something wrong&#39;</span>);
</span></span><span style="display:flex;"><span>    expect(() =&gt; doSomething()).toThrow(<span style="color:#79c0ff">/wrong/</span>);
</span></span><span style="display:flex;"><span>    expect(() =&gt; doSomething()).toThrow(<span style="color:#79c0ff">/^Something wrong!$/</span>);<span style="color:#8b949e;font-style:italic">// 若要求完全相同
</span></span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>以上是 Jest 測試框架的入門應用，如果想更深入的了解，可以參考筆者的文章： <a href="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/">Jest 框架進階應用（describe、Setup and Teardown、Mock function）</a></p>
<a class="linkcard" href="https://deanlin.net/series/frontend-testing/">
  <span class="lc-body">
    <span class="lc-title">前端單元測試</span>
    <span class="lc-desc">替前端專案導入測試：學習地圖、Jest 基礎與進階，再到 Vuetify（Vite &amp; TypeScript）中的 @vue/test-utils。</span>
    <span class="lc-site"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6"/></svg>林鼎淵 Dean Lin</span>
  </span>
</a>

]]></content:encoded>
    </item>
    <item>
      <title>想幫前端專案導入測試框架？也許我的想法與學習地圖能給你一點方向</title>
      <link>https://deanlin.net/blog/202301/frontend-testing-roadmap/</link>
      <pubDate>Thu, 19 Jan 2023 10:06:38 +0800</pubDate>
      <guid>https://deanlin.net/blog/202301/frontend-testing-roadmap/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>筆者自首，過去我只撰寫後端測試，總是忽略前端測試的重要性。 當初會這樣，是因為經手的專案規格變動頻繁，感覺測試寫完沒多久就要棄用；但近期與業界的朋友聊天時，他們跟我強調了 TDD（Test-Driven Development），也就是「測試驅動開發」的概念。</description>
      <media:content url="https://deanlin.net/blog/202301/frontend-testing-roadmap/images/frontend-testing-roadmap-og_hu_e01158bc3b725f5a.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p><img src="https://deanlin.net/blog/202301/frontend-testing-roadmap/images/frontend-testing-roadmap-01.webp" width="1150" height="602" alt="想幫前端專案導入測試框架？也許我的想法與學習地圖能給你一點方向（圖 1）" fetchpriority="high" decoding="async"></p>
<pre tabindex="0"><code>大綱

一、前端為什麼需要寫測試？
二、寫測試對開發人員有哪些幫助？
三、筆者的學習地圖
</code></pre><h2 id="一前端為什麼需要寫測試">一、前端為什麼需要寫測試？<a class="h-anchor" href="#%e4%b8%80%e5%89%8d%e7%ab%af%e7%82%ba%e4%bb%80%e9%ba%bc%e9%9c%80%e8%a6%81%e5%af%ab%e6%b8%ac%e8%a9%a6" aria-label="這個段落的連結"></a></h2>
<p>筆者自首，過去我只撰寫後端測試，總是忽略前端測試的重要性。</p>
<p>當初會這樣，是因為經手的專案規格變動頻繁，感覺測試寫完沒多久就要棄用；但近期與業界的朋友聊天時，他們跟我強調了 TDD（Test-Driven Development），也就是「測試驅動開發」的概念。</p>
<p>這名詞也不是第一次聽到，但是否對專案有幫助我也是心存懷疑。</p>
<p>針對這個疑問，朋友很認真的回應我，這邊整理概要如下：</p>
<blockquote>
<p>規格之所以會頻繁變更，大多數的原因是前期規劃不良所導致。</p>
<p>有些 PM 甚至沒有技術背景只能當傳聲筒，在這種前提下訂出的規格往往非常模糊。</p>
<p>而工程師面對這種模糊的規格，他有兩條路，一條路是不管那麼多，先開發再說，如果選擇這條路，那日後頻繁的遇到規格變更實屬正常。</p>
<p>而另一條路則是「確定規格後」再開發，我們常常把沒寫測試歸咎於專案時程太緊；但其實在專案完成後做 Review 時，發現很多時候都是在白忙一場。</p>
<p>所以如果能先把模糊的地方定義清楚，並將其納入測試程式，有時反而是在節省時間，要是日後規格有異動時，也方便確認哪一塊需要調整。</p>
</blockquote>
<p>每個人學習新技術的理由不盡相同，但我就是在這次聊天後開始學習的。</p>
<blockquote class="pullquote"><p><em>其實有時候也不是沒時間，只是因為自己不熟悉測試程式，而用其他藉口逃避。</em></p>
</blockquote>
<hr>
<h2 id="二寫測試對開發人員有哪些幫助">二、寫測試對開發人員有哪些幫助？<a class="h-anchor" href="#%e4%ba%8c%e5%af%ab%e6%b8%ac%e8%a9%a6%e5%b0%8d%e9%96%8b%e7%99%bc%e4%ba%ba%e5%93%a1%e6%9c%89%e5%93%aa%e4%ba%9b%e5%b9%ab%e5%8a%a9" aria-label="這個段落的連結"></a></h2>
<p>系統會越來越複雜，公司的同事會來來去去，不管是為了自己還是同事，有測試程式對我們最直接的幫助有：</p>
<ul>
<li><strong>減少人工測試</strong>：如果每次 release 新的版本就要全部手動測試一次，你絕對會崩潰。</li>
<li><strong>減少測試上的遺漏</strong>：如果是人工測試，那就可能有「啊！這功能之前都沒問題，這次就不用測了」的想法，然後上線後就爆了；不過交給機器跑就不會有這個問題。</li>
<li><strong>避免調整程式後，一堆功能壞掉而不自知</strong>：無論是重構程式/新增功能/解決 Bug，你勢必都會動到程式邏輯，很有可能你完成了新功能，但上線後才發現其他相依的功能壞掉了（程式也是有蝴蝶效應的）。</li>
<li><strong>未來的同事（自己）會感謝你</strong>：專案可能上線後一兩年都沒事，但當需要新增/調整功能時，如果維護的人發現前人沒有測試程式，那改程式就跟走鋼索一樣（那個人很可能是自己）。</li>
</ul>
<hr>
<h2 id="三筆者的學習地圖">三、筆者的學習地圖<a class="h-anchor" href="#%e4%b8%89%e7%ad%86%e8%80%85%e7%9a%84%e5%ad%b8%e7%bf%92%e5%9c%b0%e5%9c%96" aria-label="這個段落的連結"></a></h2>
<p>把知識集中在一篇文章會讀得太辛苦，這邊筆者將其規劃成以下主題供大家閱讀。</p>
<h3 id="-認識-jest-測試框架開始動手寫測試程式link">認識 Jest 測試框架，開始動手寫測試程式：<a href="https://deanlin.net/blog/202301/jest-unit-testing-typescript/">link</a><a class="h-anchor" href="#-%e8%aa%8d%e8%ad%98-jest-%e6%b8%ac%e8%a9%a6%e6%a1%86%e6%9e%b6%e9%96%8b%e5%a7%8b%e5%8b%95%e6%89%8b%e5%af%ab%e6%b8%ac%e8%a9%a6%e7%a8%8b%e5%bc%8flink" aria-label="這個段落的連結"></a></h3>
<ul>
<li>初始化專案、設定測試環境</li>
<li>了解單元測試的結構，撰寫程式</li>
<li>了解 Jest 常用的匹配器（Matchers）</li>
</ul>
<h3 id="-jest-框架進階應用describesetup-and-teardownmock-functionlink">Jest 框架進階應用（describe、Setup and Teardown、Mock function）：<a href="https://deanlin.net/blog/202301/jest-advanced-mock-setup-teardown/">link</a><a class="h-anchor" href="#-jest-%e6%a1%86%e6%9e%b6%e9%80%b2%e9%9a%8e%e6%87%89%e7%94%a8describesetup-and-teardownmock-functionlink" aria-label="這個段落的連結"></a></h3>
<ul>
<li>測試分組（describe）</li>
<li>在測試前、後要執行的任務（Setup and Teardown）</li>
<li>用 Mock Function 減少測試時的相依性</li>
<li>VSCode 的 Jest 外掛</li>
</ul>
<h3 id="-在-vuetifyvite--typescript中導入單元測試-unit-testinglink">在 Vuetify（Vite &amp; TypeScript）中導入單元測試 Unit Testing：<a href="https://deanlin.net/blog/202301/vuetify-vite-unit-testing/">link</a><a class="h-anchor" href="#-%e5%9c%a8-vuetifyvite--typescript%e4%b8%ad%e5%b0%8e%e5%85%a5%e5%96%ae%e5%85%83%e6%b8%ac%e8%a9%a6-unit-testinglink" aria-label="這個段落的連結"></a></h3>
<ul>
<li>初始化專案、安裝必要套件</li>
<li>調整專案各項設定檔，讓單元測試可以運行</li>
<li>撰寫第一隻測試程式</li>
<li>運行測試程式確認結果符合預期</li>
</ul>
<h3 id="-透過實際案例了解在-vuetifyvite--typescript中如何運用-vuetest-utilslink">透過實際案例了解在 Vuetify（Vite &amp; TypeScript）中如何運用 @vue/test-utils：<a href="https://deanlin.net/blog/202301/vue-test-utils-examples/">link</a><a class="h-anchor" href="#-%e9%80%8f%e9%81%8e%e5%af%a6%e9%9a%9b%e6%a1%88%e4%be%8b%e4%ba%86%e8%a7%a3%e5%9c%a8-vuetifyvite--typescript%e4%b8%ad%e5%a6%82%e4%bd%95%e9%81%8b%e7%94%a8-vuetest-utilslink" aria-label="這個段落的連結"></a></h3>
<ul>
<li>從測試的角度來設計頁面規格（以登入頁為範例）</li>
<li>撰寫登入頁程式</li>
<li>從測試案例了解 @vue/test-utils 常用的功能（ex：getComponent、setValue、wrapper.vm、trigger button、Vue Router）</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>透過 Jaeger with OpenTelemetry 追蹤 Kafka 資料傳遞路徑與運行狀況</title>
      <link>https://deanlin.net/blog/202211/trace-kafka-with-jaeger/</link>
      <pubDate>Wed, 23 Nov 2022 23:11:29 +0800</pubDate>
      <guid>https://deanlin.net/blog/202211/trace-kafka-with-jaeger/</guid>
      <dc:creator>林鼎淵</dc:creator>
      <category>技術教學</category>
      <description>我們可以把 Kafka 當成傳遞消息的「轉運站」，但有時就算 Consumer 順利拿到 Producer 的消息，我們也無法保證接下來的運作都符合預期。</description>
      <media:content url="https://deanlin.net/blog/202211/trace-kafka-with-jaeger/images/trace-kafka-with-jaeger-og_hu_e4284c2e62441bb9.jpg" medium="image" type="image/jpeg" width="1200" height="675" />
      <content:encoded><![CDATA[<p>我們可以把 Kafka 當成傳遞消息的「轉運站」，但有時就算 Consumer 順利拿到 Producer 的消息，我們也無法保證接下來的運作都符合預期。</p>
<p>而且會用到 Kafka 的系統，大多以微服務（Microservices）為主；若沒有搭配合適的追蹤系統，我們很難排查 Consumer 收到消息後發生的錯誤。</p>
<p><img src="https://deanlin.net/blog/202211/trace-kafka-with-jaeger/images/trace-kafka-with-jaeger-01.webp" width="1400" height="726" alt="透過 Jaeger with OpenTelemetry 追蹤 Kafka 資料傳遞路徑與運行狀況（圖 1）" fetchpriority="high" decoding="async"></p>
<blockquote>
<p>這篇文章以實作和邏輯講解為主，如果想了解 Jaeger 這個分布式追蹤系統，請參考<a href="https://deanlin.net/blog/202211/distributed-tracing-jaeger-opentelemetry/">連結</a>；想了解 Kafka 這個分布式消息串流平台，請參考<a href="https://deanlin.net/blog/202211/what-is-kafka/">連結1</a>、<a href="https://deanlin.net/blog/202211/nodejs-kafka-hands-on/">連結2</a></p>
</blockquote>
<p>建議讀者直接到<a href="https://github.com/dean9703111/kafka-jaeger-demo">筆者的 Github</a> Clone 一份下來跑跑看，會更好理解裏面的邏輯（順手按個 ⭐ 更好 🤩）。</p>
<pre tabindex="0"><code>大綱

一、自定義追蹤的 Parent &amp; Child 關聯（Manually propagating span context）
二、使用 Kafka 作為傳遞資料的轉運站
三、在 Local 建立 Node.js 專案來達成目標
四、啟動專案，透過 Jaeger UI 確認 Trace 路徑是否符合預期
</code></pre><h2 id="ㄧ自定義追蹤的-parent--child-關聯manually-propagating-span-context">ㄧ、自定義追蹤的 Parent &amp; Child 關聯（Manually propagating span context）<a class="h-anchor" href="#%e3%84%a7%e8%87%aa%e5%ae%9a%e7%be%a9%e8%bf%bd%e8%b9%a4%e7%9a%84-parent--child-%e9%97%9c%e8%81%afmanually-propagating-span-context" aria-label="這個段落的連結"></a></h2>
<p>其實 OpenTelemetry 有提供許多 Instrumentation 的方案，如：</p>
<ul>
<li><a href="https://www.npmjs.com/package/@opentelemetry/instrumentation-http">@opentelemetry/instrumentation-http</a></li>
<li><a href="https://www.npmjs.com/package/@opentelemetry/instrumentation-grpc">@opentelemetry/instrumentation-grpc</a></li>
<li><a href="https://www.npmjs.com/package/opentelemetry-instrumentation-kafkajs">opentelemetry-instrumentation-kafkajs</a></li>
</ul>
<p>透過上面的套件可以自動產生追蹤紀錄，但有時我們只需要追蹤特定路徑，而使用上面的套件會產生許多不需要的資訊，還容易產生效能瓶頸。</p>
<blockquote>
<p>比如使用「instrumentation-http」這款套件時，它會把所有 http 相關的操作都記錄下來，但我們實際上只關心其中的幾個路徑。</p>
</blockquote>
<p>因此在某些情況下，自定義追蹤的 Parent &amp; Child 關聯是有必要的；下面筆者擷取關鍵的程式碼來做說明：</p>
<h3 id="-透過-w3ctracecontextpropagator-把-span-資訊-inject-到-carrier">透過 W3CTraceContextPropagator 把 span 資訊 inject 到 carrier<a class="h-anchor" href="#-%e9%80%8f%e9%81%8e-w3ctracecontextpropagator-%e6%8a%8a-span-%e8%b3%87%e8%a8%8a-inject-%e5%88%b0-carrier" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">let</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">&#39;example&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> carrier <span style="color:#ff7b72;font-weight:bold">=</span> {};
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> propagator <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> W3CTraceContextPropagator();
</span></span><span style="display:flex;"><span>propagator.inject(
</span></span><span style="display:flex;"><span>    api.trace.setSpanContext(api.context.active(), span.spanContext()),
</span></span><span style="display:flex;"><span>    carrier,
</span></span><span style="display:flex;"><span>    api.defaultTextMapSetter,
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>span.end()
</span></span></code></pre></div><h3 id="-透過-w3ctracecontextpropagator-extract-carrier-資訊使其成為能使用的-context">透過 W3CTraceContextPropagator extract carrier 資訊，使其成為能使用的 Context<a class="h-anchor" href="#-%e9%80%8f%e9%81%8e-w3ctracecontextpropagator-extract-carrier-%e8%b3%87%e8%a8%8a%e4%bd%bf%e5%85%b6%e6%88%90%e7%82%ba%e8%83%bd%e4%bd%bf%e7%94%a8%e7%9a%84-context" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> propagator <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> W3CTraceContextPropagator();
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 解析 carrier
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> parentCtx <span style="color:#ff7b72;font-weight:bold">=</span> propagator.extract(api.context.active(), carrier, api.defaultTextMapGetter);
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 設定 parent 關聯
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">`report_consumer`</span>, <span style="color:#79c0ff">undefined</span>, parentCtx);
</span></span><span style="display:flex;"><span>span.end()
</span></span></code></pre></div><hr>
<h2 id="二使用-kafka-作為傳遞資料的轉運站">二、使用 Kafka 作為傳遞資料的轉運站<a class="h-anchor" href="#%e4%ba%8c%e4%bd%bf%e7%94%a8-kafka-%e4%bd%9c%e7%82%ba%e5%82%b3%e9%81%9e%e8%b3%87%e6%96%99%e7%9a%84%e8%bd%89%e9%81%8b%e7%ab%99" aria-label="這個段落的連結"></a></h2>
<p>假使今天有個功能是「產出報表」，但它的執行時間長達「3 分鐘」；我想正常的使用者是不會等一隻 API 回傳等這麼久，針對這個議題，我們不妨改個思路：</p>
<p><strong>SETP 1</strong>：在報表的 Table 新增一個「status」欄位，用來表達報表建立的狀況（init、processing、done、fail）。</p>
<p><strong>SETP 2</strong>：前端呼叫產出報表的 API 時</p>
<ul>
<li>在報表的 Table 新增初始資訊，並將 Status 設定為「init」。</li>
<li>將相關資訊透過 Producer 塞入 Kafka。</li>
</ul>
<p><strong>SETP 3</strong>：前端收到 API 回傳的報表 id 後</p>
<ul>
<li>透過 Consumer 監聽報表執行的狀況；可以用報表的 id 作為 Topic 命名邏輯，ex：reports/{id}。</li>
</ul>
<p><strong>SETP 4</strong>：監聽產出報表的 Consumer 從 Kafka 收到命令後</p>
<ul>
<li>透過 id 從資料庫撈出對應的報表資訊，將 Status 設定為「processing」。</li>
<li>呼叫相關服務產出報表，如果其中一個服務發生錯誤，就將 Status 設定為「fail」。</li>
<li>如果報表順利產出，就將 Status 設定為「done」。</li>
</ul>
<blockquote>
<p>對 Status 的任何變動，都會透過 Producer 將資訊傳遞到 <strong>reports/{id} </strong>這個 Topic，讓前端監聽的的 Consumer 可以作出對應的變動。</p>
</blockquote>
<p>上面是比較完整的業務邏輯，建議讀者可以自行實做看看，下面筆者就單純示範透過 Kafka 傳遞 carrier 資訊的部分：</p>
<h3 id="-在-producer-的-value-放上-carrier-的資訊記得要轉為字串">在 Producer 的 value 放上 carrier 的資訊（記得要轉為字串）<a class="h-anchor" href="#-%e5%9c%a8-producer-%e7%9a%84-value-%e6%94%be%e4%b8%8a-carrier-%e7%9a%84%e8%b3%87%e8%a8%8a%e8%a8%98%e5%be%97%e8%a6%81%e8%bd%89%e7%82%ba%e5%ad%97%e4%b8%b2" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">await</span> producer.connect()
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">await</span> producer
</span></span><span style="display:flex;"><span>  .send({
</span></span><span style="display:flex;"><span>    topic,
</span></span><span style="display:flex;"><span>    messages<span style="color:#ff7b72;font-weight:bold">:</span> Array({
</span></span><span style="display:flex;"><span>      key<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">`report`</span>,
</span></span><span style="display:flex;"><span>      value<span style="color:#ff7b72;font-weight:bold">:</span> JSON.stringify(carrier)
</span></span><span style="display:flex;"><span>    })
</span></span><span style="display:flex;"><span>  });
</span></span></code></pre></div><h3 id="-從-consumer-收到的-message-中取出-carrier-的資訊">從 Consumer 收到的 message 中取出 carrier 的資訊<a class="h-anchor" href="#-%e5%be%9e-consumer-%e6%94%b6%e5%88%b0%e7%9a%84-message-%e4%b8%ad%e5%8f%96%e5%87%ba-carrier-%e7%9a%84%e8%b3%87%e8%a8%8a" aria-label="這個段落的連結"></a></h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">await</span> consumer.connect()
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">await</span> consumer.subscribe({ topic, fromBeginning<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">true</span> })
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">await</span> consumer.run({
</span></span><span style="display:flex;"><span>  eachMessage<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">async</span> ({ topic, partition, message }) =&gt; {
</span></span><span style="display:flex;"><span>    console.log(<span style="color:#a5d6ff">&#34;topic:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> topic <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;  message:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> message.value)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> carrier <span style="color:#ff7b72;font-weight:bold">=</span> JSON.parse(message.value)
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><hr>
<h2 id="三在-local-建立-nodejs-專案來達成目標">三、在 Local 建立 Node.js 專案來達成目標<a class="h-anchor" href="#%e4%b8%89%e5%9c%a8-local-%e5%bb%ba%e7%ab%8b-nodejs-%e5%b0%88%e6%a1%88%e4%be%86%e9%81%94%e6%88%90%e7%9b%ae%e6%a8%99" aria-label="這個段落的連結"></a></h2>
<p>專案的目標就如同標題，我們要透過 Jaeger with OpenTelemetry 追蹤 Kafka 資料傳遞路徑與運行狀況。</p>
<p><strong>STEP 1</strong>：建立專案、安裝必要套件。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir -p kafka-jaeger-demo
</span></span><span style="display:flex;"><span>cd kafka-jaeger-demo
</span></span><span style="display:flex;"><span>mkdir -p deploy/kafkaCluster/kraft
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>npm init -f
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>npm install --save @opentelemetry/api
</span></span><span style="display:flex;"><span>npm install --save @opentelemetry/resources
</span></span><span style="display:flex;"><span>npm install --save @opentelemetry/semantic-conventions
</span></span><span style="display:flex;"><span>npm install --save @opentelemetry/sdk-trace-base
</span></span><span style="display:flex;"><span>npm install --save @opentelemetry/exporter-jaeger
</span></span><span style="display:flex;"><span>npm install --save axios
</span></span><span style="display:flex;"><span>npm install --save express
</span></span><span style="display:flex;"><span>npm install --save kafkajs
</span></span></code></pre></div><p><strong>SETP 2</strong>：新增「docker-compose.yml」貼上如下程式，並將 docker 啟動。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;2.0&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">jaeger</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">jaegertracing/all-in-one:1.38</span><span style="color:#6e7681"> </span><span style="color:#8b949e;font-style:italic"># 當時用 1.39 或 latest 有出現一些 bug，故選用此版本</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">COLLECTOR_ZIPKIN_HOST_PORT=:9411</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;5775:5775/udp&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;6831:6831/udp&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;6832:6832/udp&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;5778:5778&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;16686:16686&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;14250:14250&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;14268:14268&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;14269:14269&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;9411:9411&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">kafka</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;bitnami/kafka:latest&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#39;9092:9092&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_ENABLE_KRAFT=yes</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_PROCESS_ROLES=broker,controller</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_BROKER_ID=1</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@127.0.0.1:9093</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">ALLOW_PLAINTEXT_LISTENER=yes</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#8b949e;font-style:italic"># 這是設定資料要存在哪路的路徑</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./deploy/kafkaCluster/kraft:/bitnami/kafka:rw</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>接著輸入指令拉下並啟動 Kafka &amp; Jaeger 環境。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker-compose up -d
</span></span></code></pre></div><p><strong>STEP 3</strong>：新增「tracing.js」貼上如下程式，基本上就是「Opentelemetry」的基礎運用。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#a5d6ff">&#39;use strict&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> opentelemetry <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/api&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { Resource } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/resources&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { SemanticResourceAttributes } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/semantic-conventions&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { BasicTracerProvider, BatchSpanProcessor } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/sdk-trace-base&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { JaegerExporter } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/exporter-jaeger&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>module.exports <span style="color:#ff7b72;font-weight:bold">=</span> (serviceName) =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> provider <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> BasicTracerProvider({
</span></span><span style="display:flex;"><span>        resource<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">new</span> Resource({
</span></span><span style="display:flex;"><span>            [SemanticResourceAttributes.SERVICE_NAME]<span style="color:#ff7b72;font-weight:bold">:</span> serviceName,
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">let</span> exporter;
</span></span><span style="display:flex;"><span>    exporter <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> JaegerExporter({ endpoint<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;http://127.0.0.1:14268/api/traces&#34;</span> });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    provider.addSpanProcessor(<span style="color:#ff7b72">new</span> BatchSpanProcessor(exporter));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// Initialize the OpenTelemetry APIs to use the NodeTracerProvider bindings
</span></span></span><span style="display:flex;"><span>    provider.register();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> opentelemetry.trace.getTracer(<span style="color:#a5d6ff">&#39;my-service-tracer&#39;</span>);
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p><strong>STEP 4</strong>：新增「service.js」貼上如下程式；我們在這裡建立供使用者呼叫的 API（/api/report），當 API 被呼叫後會開始追蹤，並透過 Producer 將資料塞入 Kafka。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> api <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/api&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> tracer <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;./tracing&#39;</span>)(<span style="color:#a5d6ff">&#39;service&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> express <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#34;express&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> PORT <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#34;8080&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> app <span style="color:#ff7b72;font-weight:bold">=</span> express();
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { Kafka, logLevel } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;kafkajs&#39;</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> kafka <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Kafka({
</span></span><span style="display:flex;"><span>  logLevel<span style="color:#ff7b72;font-weight:bold">:</span> logLevel.ERROR,
</span></span><span style="display:flex;"><span>  brokers<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">`localhost:9092`</span>],
</span></span><span style="display:flex;"><span>  clientId<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;example-producer&#39;</span>,
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> producer <span style="color:#ff7b72;font-weight:bold">=</span> kafka.producer()
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> topic <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#39;report&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { W3CTraceContextPropagator } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/core&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">async</span> <span style="color:#ff7b72">function</span> report(parentSpan) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> ctx <span style="color:#ff7b72;font-weight:bold">=</span> api.trace.setSpan(
</span></span><span style="display:flex;"><span>      api.context.active(),
</span></span><span style="display:flex;"><span>      parentSpan
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">let</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">&#39;report_producer&#39;</span>, <span style="color:#79c0ff">undefined</span>, ctx);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> carrier <span style="color:#ff7b72;font-weight:bold">=</span> {};
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> propagator <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> W3CTraceContextPropagator();
</span></span><span style="display:flex;"><span>    propagator.inject(
</span></span><span style="display:flex;"><span>      api.trace.setSpanContext(api.context.active(), span.spanContext()),
</span></span><span style="display:flex;"><span>      carrier,
</span></span><span style="display:flex;"><span>      api.defaultTextMapSetter,
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> producer.connect()
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">await</span> producer
</span></span><span style="display:flex;"><span>      .send({
</span></span><span style="display:flex;"><span>        topic,
</span></span><span style="display:flex;"><span>        messages<span style="color:#ff7b72;font-weight:bold">:</span> Array({
</span></span><span style="display:flex;"><span>          key<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">`report`</span>,
</span></span><span style="display:flex;"><span>          value<span style="color:#ff7b72;font-weight:bold">:</span> JSON.stringify(carrier)
</span></span><span style="display:flex;"><span>        })
</span></span><span style="display:flex;"><span>      });
</span></span><span style="display:flex;"><span>    span.end()
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">catch</span> (e) {
</span></span><span style="display:flex;"><span>    console.log(e)
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">throw</span> e
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>app.get(<span style="color:#a5d6ff">&#34;/api/report&#34;</span>, <span style="color:#ff7b72">async</span> (req, res) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">let</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">&#39;report_request&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">let</span> result <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> report(span)
</span></span><span style="display:flex;"><span>    span.end()
</span></span><span style="display:flex;"><span>    res.send(result);
</span></span><span style="display:flex;"><span>  } <span style="color:#ff7b72">catch</span> (e) {
</span></span><span style="display:flex;"><span>    res.send(e);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app.listen(parseInt(PORT, <span style="color:#a5d6ff">10</span>), () =&gt; {
</span></span><span style="display:flex;"><span>  console.log(<span style="color:#a5d6ff">`Service listening for requests on http://localhost:</span><span style="color:#a5d6ff">${</span>PORT<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>);
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p><strong>STEP 5</strong>：新增「consumer.js」貼上如下程式；專門監聽「report」這個 Topic，在收到 message 後會開始後續的處理（有刻意設計一個執行錯誤的結果）。</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> api <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/api&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> tracer <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;./tracing&#39;</span>)(<span style="color:#a5d6ff">&#39;consumer&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { W3CTraceContextPropagator } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;@opentelemetry/core&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> { Kafka, logLevel } <span style="color:#ff7b72;font-weight:bold">=</span> require(<span style="color:#a5d6ff">&#39;kafkajs&#39;</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> kafka <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> Kafka({
</span></span><span style="display:flex;"><span>  logLevel<span style="color:#ff7b72;font-weight:bold">:</span> logLevel.INFO,
</span></span><span style="display:flex;"><span>  brokers<span style="color:#ff7b72;font-weight:bold">:</span> [<span style="color:#a5d6ff">`localhost:9092`</span>],
</span></span><span style="display:flex;"><span>  clientId<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;example-consumer&#39;</span>,
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> topic <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">&#39;report&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> consumer <span style="color:#ff7b72;font-weight:bold">=</span> kafka.consumer({ groupId<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;test-group&#39;</span> })
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> run <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> consumer.connect()
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> consumer.subscribe({ topic, fromBeginning<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#79c0ff">true</span> })
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">await</span> consumer.run({
</span></span><span style="display:flex;"><span>    eachMessage<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">async</span> ({ topic, partition, message }) =&gt; {
</span></span><span style="display:flex;"><span>      console.log(<span style="color:#a5d6ff">&#34;topic:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> topic <span style="color:#ff7b72;font-weight:bold">+</span> <span style="color:#a5d6ff">&#34;  message:&#34;</span> <span style="color:#ff7b72;font-weight:bold">+</span> message.value)
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> carrier <span style="color:#ff7b72;font-weight:bold">=</span> JSON.parse(message.value)
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> propagator <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> W3CTraceContextPropagator();
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> parentCtx <span style="color:#ff7b72;font-weight:bold">=</span> propagator.extract(api.context.active(), carrier, api.defaultTextMapGetter);
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (topic <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">&#34;report&#34;</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">const</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">`report_consumer`</span>, <span style="color:#79c0ff">undefined</span>, parentCtx);
</span></span><span style="display:flex;"><span>        report(span)
</span></span><span style="display:flex;"><span>        span.end()
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  })
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> report(parentSpan) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> parentCtx <span style="color:#ff7b72;font-weight:bold">=</span> api.trace.setSpan(
</span></span><span style="display:flex;"><span>    api.context.active(),
</span></span><span style="display:flex;"><span>    parentSpan
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">let</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">&#39;handle_report&#39;</span>, <span style="color:#79c0ff">undefined</span>, parentCtx);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">let</span> i <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; i <span style="color:#ff7b72;font-weight:bold">&lt;</span> <span style="color:#a5d6ff">10</span>; i<span style="color:#ff7b72;font-weight:bold">++</span>) {
</span></span><span style="display:flex;"><span>    doWork(span, i);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  span.end();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">function</span> doWork(parentSpan, i) {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> parentCtx <span style="color:#ff7b72;font-weight:bold">=</span> api.trace.setSpan(
</span></span><span style="display:flex;"><span>    api.context.active(),
</span></span><span style="display:flex;"><span>    parentSpan
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">let</span> span <span style="color:#ff7b72;font-weight:bold">=</span> tracer.startSpan(<span style="color:#a5d6ff">`doWork:</span><span style="color:#a5d6ff">${</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>, <span style="color:#79c0ff">undefined</span>, parentCtx);
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// 讓每個執行的時間不一樣
</span></span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">let</span> i <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">0</span>; i <span style="color:#ff7b72;font-weight:bold">&lt;=</span> Math.floor(Math.random() <span style="color:#ff7b72;font-weight:bold">*</span> <span style="color:#a5d6ff">40000000</span>); i<span style="color:#ff7b72;font-weight:bold">++</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic">// empty
</span></span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">if</span> (i <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">5</span>) { <span style="color:#8b949e;font-style:italic">// 產生錯誤訊息
</span></span></span><span style="display:flex;"><span>    span.recordException(<span style="color:#ff7b72">new</span> Error(<span style="color:#a5d6ff">`doWork:</span><span style="color:#a5d6ff">${</span>i<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff"> error`</span>));
</span></span><span style="display:flex;"><span>    span.setStatus({ code<span style="color:#ff7b72;font-weight:bold">:</span> api.SpanStatusCode.ERROR, message<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;Something wrong!&#34;</span> });
</span></span><span style="display:flex;"><span>    span.addEvent(<span style="color:#a5d6ff">&#39;log&#39;</span>, {
</span></span><span style="display:flex;"><span>      <span style="color:#a5d6ff">&#39;log.severity&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;error&#39;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a5d6ff">&#39;log.message&#39;</span><span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;doWork:${i} error&#39;</span>
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  span.end();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>run().<span style="color:#ff7b72">catch</span>(e =&gt; console.error(<span style="color:#a5d6ff">`[example/consumer] </span><span style="color:#a5d6ff">${</span>e.message<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>, e))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> errorTypes <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">&#39;unhandledRejection&#39;</span>, <span style="color:#a5d6ff">&#39;uncaughtException&#39;</span>]
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">const</span> signalTraps <span style="color:#ff7b72;font-weight:bold">=</span> [<span style="color:#a5d6ff">&#39;SIGTERM&#39;</span>, <span style="color:#a5d6ff">&#39;SIGINT&#39;</span>, <span style="color:#a5d6ff">&#39;SIGUSR2&#39;</span>]
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 收到無法處理錯誤時的處理
</span></span></span><span style="display:flex;"><span>errorTypes.forEach(type =&gt; {
</span></span><span style="display:flex;"><span>  process.on(type, <span style="color:#ff7b72">async</span> e =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>      console.log(<span style="color:#a5d6ff">`process.on </span><span style="color:#a5d6ff">${</span>type<span style="color:#a5d6ff">}</span><span style="color:#a5d6ff">`</span>)
</span></span><span style="display:flex;"><span>      console.error(e)
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">await</span> consumer.disconnect()
</span></span><span style="display:flex;"><span>      process.exit(<span style="color:#a5d6ff">0</span>)
</span></span><span style="display:flex;"><span>    } <span style="color:#ff7b72">catch</span> (_) {
</span></span><span style="display:flex;"><span>      process.exit(<span style="color:#a5d6ff">1</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  })
</span></span><span style="display:flex;"><span>})
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">// 收到中斷訊號時的處理
</span></span></span><span style="display:flex;"><span>signalTraps.forEach(type =&gt; {
</span></span><span style="display:flex;"><span>  process.once(type, <span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">try</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">await</span> consumer.disconnect()
</span></span><span style="display:flex;"><span>    } <span style="color:#ff7b72">finally</span> {
</span></span><span style="display:flex;"><span>      process.kill(process.pid, type)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  })
</span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><hr>
<h2 id="四啟動專案透過-jaeger-ui-確認-trace-路徑是否符合預期">四、啟動專案，透過 Jaeger UI 確認 Trace 路徑是否符合預期<a class="h-anchor" href="#%e5%9b%9b%e5%95%9f%e5%8b%95%e5%b0%88%e6%a1%88%e9%80%8f%e9%81%8e-jaeger-ui-%e7%a2%ba%e8%aa%8d-trace-%e8%b7%af%e5%be%91%e6%98%af%e5%90%a6%e7%ac%a6%e5%90%88%e9%a0%90%e6%9c%9f" aria-label="這個段落的連結"></a></h2>
<blockquote>
<p>建議使用有分頁功能的終端機（如 iTerm2），這樣比較好觀察與操作。</p>
</blockquote>
<p><strong>STEP 1</strong>：在專案根目錄下輸入 <code>node service.js</code> 啟動 Service。</p>
<p><strong>STEP 2</strong>：在終端機貼上指令 <code>curl http://localhost:8080/api/report</code> 來呼叫 API。</p>
<p><strong>STEP 3</strong>：在專案根目錄下輸入 <code>node consumer.js</code> 啟動監聽模式；這邊提醒一下，如果監聽不存在的 Topic 會報錯。</p>
<p><img src="https://deanlin.net/blog/202211/trace-kafka-with-jaeger/images/trace-kafka-with-jaeger-02.webp" width="1400" height="1058" alt="四、啟動專案，透過 Jaeger UI 確認 Trace 路徑是否符合預期（圖 2）" loading="lazy" decoding="async"></p>
<p><strong>STEP 4</strong>：到 Jaeger UI 上（<a href="http://localhost:16686/search">http://localhost:16686/search</a>）確認 Trace 路徑是否符合預期。</p>
<figure class="figure"><img src="https://deanlin.net/blog/202211/trace-kafka-with-jaeger/images/trace-kafka-with-jaeger-03.webp" width="1400" height="728" alt="上面是呼叫 API 時留下的紀錄" loading="lazy" decoding="async">
  <figcaption>上面是呼叫 API 時留下的紀錄</figcaption>
</figure>

<p>下圖「report_producer」跟「report_consumer」之間的空白，就是資料在 Kafka 轉運站傳遞的時間。</p>
<figure class="figure"><img src="https://deanlin.net/blog/202211/trace-kafka-with-jaeger/images/trace-kafka-with-jaeger-04.webp" width="1400" height="596" alt="我們可以看到 trace 中每個 span 執行的狀況（這邊能看到錯誤是出現在 dowork5 的 span 上）" loading="lazy" decoding="async">
  <figcaption>我們可以看到 trace 中每個 span 執行的狀況（這邊能看到錯誤是出現在 dowork5 的 span 上）</figcaption>
</figure>

<blockquote class="pullquote"><p>範例到這裡告一段落，希望文中 Jaeger 與 Kafka 的組合應用，有幫讀者打開不一樣的思路。</p>
</blockquote>
]]></content:encoded>
    </item>
  </channel>
</rss>
