標籤
黑色卡片上的綠色 Upstash 雙螺旋標誌與白色字樣

在靜態的 Astro 部落格上統計「喜歡」 — 一條 Vercel 路由與 Upstash Redis

為完全靜態的 Astro 網站加上共用的喜歡計數:只多一條非預先渲染的路由、Upstash Redis 的鍵設計,以及為了驗證它而付出的 CI 錯誤。

本頁目錄

引言

我想在文章上加一個喜歡按鈕。最初的直覺是一個按鈕可以做兩件事,因為喜歡和書籤說到底都是「這篇有價值」;而對一個沒有帳號的部落格來說,把它存在 localStorage 才自然。

代理對這兩點都不同意,而第二點的論據才是有用的部分:只存在讀者瀏覽器裡的喜歡,什麼也沒有告訴任何人。 讀者本來就有書籤,不會多得到什麼。我這邊則完全一無所知,而那正是我想要這個按鈕的理由。所以我選了要付出代價的那個版本:一個真正共用的計數器,也就是要在一個從來沒有伺服器的網站後面放上伺服器。

本文整理讓這個計數器值得做的設計理由、一條無伺服器路由實際上對靜態網站收取多少成本,以及在任何東西能證明端點可用之前所踩到的三個 CI 錯誤。

只有累計數的計數不算證據

最直覺的端點就是把一個數字加一。那個版本只要一次 curl 迴圈就失去意義,文章底下的數字也不再是任何事情的證據。

所以每一次喜歡寫入的是兩筆紀錄,而不是一筆:

src/lib/likes.ts
const countKey = (slug: string) => `${KEY_PREFIX}likes:${slug}`;
const voteKey = (slug: string, voter: string) => `${KEY_PREFIX}likes:by:${slug}:${voter}`;

第二筆才讓第一筆有意義。它記錄某個呼叫端目前持有一個有效的喜歡,索引鍵是把該位址與一組永遠不會離開部署環境的密鑰一起計算出的 SHA-256 雜湊,並在 30 天後自行刪除。

一次喜歡會寫入累計數與投票紀錄,而投票紀錄才讓累計數成為證據並列顯示兩把鍵。第一把 likes:<slug> 是累計數,為沒有 TTL 的單純整數。第二把 likes:by:<slug>:<hash> 是投票紀錄,是呼叫端位址的加鹽 SHA-256,30 天後失效。下方列出第二把鍵換來的三件事:重複請求不會改變數字、可以取消喜歡、aria-pressed 才誠實。少了它,一個迴圈就讓數字失去意義。一次「喜歡」會寫入什麼累計數likes:<slug>單純的整數,沒有 TTL。投票紀錄likes:by:<slug>:<hash>位址的加鹽 SHA-256,30 天後失效。第二把鍵換來什麼重複請求不會改變數字可以取消喜歡aria-pressed 才誠實少了它一個迴圈就讓數字失去意義
投票紀錄才讓累計數成為證據,而不只是一個數字。它同時也讓取消喜歡成為可能。

TTL 想了一陣子。永久保存是錯的,因為家用連線的位址會重新配置,永久紀錄會默默把下一個接手該位址的人擋在外面。24 小時同樣是錯的:那等於讓同一位讀者每天都能再喜歡一次,永遠如此,也就等於沒有在計數。30 天是折衷,而且這件事寫在網站的隱私權頁面上,不是留給讀者自行猜測。

投票紀錄還帶來一件原本不是目標的事。因為伺服器知道一個喜歡是否仍然有效,它就可以被收回:

src/lib/likes.ts
if (liked) {
// `nx` is what makes this idempotent: it writes only if no vote exists,
// and returns null when one already did.
const claimed = await store.set(vote, 1, { nx: true, ex: VOTE_TTL_SECONDS });
if (claimed === null) return { count: await readCount(slug), liked: true };
return { count: await store.incr(countKey(slug)), liked: true };
}

這個 nx 確實在做事。兩個分頁、連點兩下,或是回應中斷後的重送,都會落到 claimed === null 這個分支,回報真正的狀態而不動到累計數。也因為取消喜歡存在,這個按鈕才能誠實地掛上 aria-pressed,而不是一個披著切換外觀的單向操作。

一條無伺服器路由對靜態網站收取的成本

Astro 仍然維持 output: 'static'。每一頁都還是預先產生,加上轉接器只是為了讓其中一個檔案可以脫離這個規則。

src/pages/api/likes/[slug].ts
export const prerender = false;

slug 會與一份由 getCollection('blog') 建立的允許清單比對,因此呼叫端無法自己捏造索引鍵把資料庫塞滿。讀取在 CDN 快取一分鐘,這讓最常見的情況完全不會碰到函式。

快取的讀取止於 CDN,只有喜歡會抵達函式與 Redis由左至右排列讀者、Vercel CDN、函式與 Upstash Redis 四個階段。函式與資料庫都標示位於東京的 hnd1 與 ap-northeast-1。標記為 s-maxage=60 的快取讀取止於 CDN,不會抵達函式。標記為 no-store 的一次喜歡則走完整條路徑,抵達 Redis 再返回。一個請求實際走多遠讀者Vercel CDN函式hnd1,東京Upstash Redisap-northeast-1快取的讀取在此停下s-maxage=60一次喜歡走完全程no-store
從不點擊的讀者不會抵達函式。只有一次喜歡,或是一次快取未命中,才會走完全程。

路由本身是便宜的那一半。加入 @astrojs/vercel 之後,建置產物從 dist/ 移到 .vercel/output/static/,而既有的兩個步驟裡還寫著舊路徑。這個失敗以最糟的方式保持沉默pagefind --site <missing> 會寫出一份空索引並以 0 結束,於是建置一片綠燈,送上線的卻是一個什麼都找不到的搜尋框。

package.json
"build": "astro build && pagefind --site .vercel/output/static && pnpm run pagefind:patch",
"preview": "pnpm dlx serve .vercel/output/static",

preview 那一行是第二項成本。astro preview 在有轉接器的情況下根本無法運作,所以這個腳本改成直接提供建置好的目錄。跟在 Pagefind 之後執行的日文搜尋修補也一併改了指向,並加上明確的存在檢查,讓路徑錯誤時大聲失敗,而不是只丟出一句 ENOENT

開通 Upstash,以及一個對話框裡的三個選擇

Upstash for Redis 是從 Vercel Marketplace 安裝的,而不是另外自行架設,這表示憑證會被注入專案,而不是貼在任何地方。

Upstash 在 Vercel Marketplace 的產品清單,共四張卡片:以紅框標示的 Upstash for Redis、Upstash Vector、Upstash QStash/Workflow 與 Upstash Search。每張卡片都有 Install 按鈕與價格方案。

四項產品共用同一個整合。這次需要的只有 Redis。

連接對話框問了三件事,而每一件在按下去之前都值得先知道它的後果。

Vercel 的 Install Integration 面板。Environments 之下 Production 與 Preview 已勾選,Development 未勾選。Custom Prefix 欄位顯示 STORAGE 的提示文字與 _URL 後綴。Sensitive 開關是開啟的。

環境、前綴,以及 Sensitive 開關。第三個是無法復原的那一個。

Environments 決定哪些部署拿得到憑證。Production 與 Preview 已勾選,Development 沒有,而且是刻意的。沒有憑證時端點會退回記憶體中的計數器並印出警告,所以 pnpm dev 完全不需要任何密鑰。

Custom Prefix 會改寫注入變數的名稱。當程式碼已經明確寫出變數名時,留空才是對的;加上前綴只會保證製造出一個只在正式環境以 503 現身、其他地方都看不到的不一致。

Sensitive 會以無法讀取的形式保存這些值。就正式環境的衛生而言這是對的,而它帶著後果:之後無論從儀表板、API 或 vercel env pull 都讀不回這些值。日後還需要用到的東西,只能改用輪替。

在 Vercel 上開通好的 Upstash 資料庫。Status 顯示 Available,Plan 顯示 Free。.env.local 分頁的 Quickstart 面板列出五個變數:KV_REST_API_READ_ONLY_TOKEN、KV_REST_API_TOKEN、KV_REST_API_URL、KV_URL 與 REDIS_URL,值全部被遮蔽。

五個變數會進到專案。程式碼用到的是其中兩個。

實際用到的只有 KV_REST_API_URLKV_REST_API_TOKENKV_URLREDIS_URL 是給傳統用戶端的 TCP 連線字串,而無伺服器函式無法持有 TCP 連線池,所以這裡是透過 REST 與 Redis 溝通。

免費方案是每月 500,000 個指令搭配 256 MB,Vercel 的 Hobby 方案則允許每月一百萬次函式呼叫。對個人部落格來說兩者都遠遠用不完。

區域是一個決定,不是兩個

Vercel 的函式預設放在華盛頓的 iad1,而 Hobby 方案只能有一個區域。Upstash 在建立時會詢問主要區域,而那個選擇之後無法變更

放著不管的話,這兩個預設值會湊出所有可能中最差的配置:來自日本讀者的一次喜歡要橫越太平洋抵達函式,再折返回來抵達東京的資料庫。本站三個語系裡有兩個是日文與繁體中文,所以我把兩邊都選在東京。

vercel.json
{ "regions": ["hnd1"] }

Vercel 的區域名稱沿用 AWS 的命名,所以 hnd1 與 Upstash 的 ap-northeast-1 是同一座資料中心。

同一個資料庫,兩個鍵空間

這個整合會把資料庫同時接到 Production 與 Preview,也就是說在測試分支時按下的喜歡,會動到已發布文章底下的數字。

同一個 Redis 資料庫持有兩個鍵空間,預覽的喜歡無法改變已發布的累計數圖中一個 Upstash Redis 資料庫同時連到兩個環境。VERCEL_ENV 為 production 的正式環境欄使用不加前綴的鍵 likes:<slug>,日後不必搬移。VERCEL_ENV 為 preview 的預覽環境欄使用 preview:likes:<slug>,分支上的寫入都會落在那裡。下方是用來證明分離的檢查:在預覽按下喜歡,再讀正式環境的數字,確認數字沒有改變。同一個資料庫,同時連到兩個環境Upstash Redis正式環境VERCEL_ENV=productionlikes:<slug>不加前綴,日後也不必搬移。預覽環境VERCEL_ENV=previewpreview:likes:<slug>分支上的寫入都會落在這裡。用來證明的檢查在預覽按下喜歡,再讀正式環境的數字。數字不能改變。
同一個資料庫,兩個鍵空間。正式環境的鍵維持原本的名稱,所以日後就算拿掉這段,也沒有東西需要搬移。

修正只有三行,依據的是 Vercel 為每一次部署都會設定的變數:

src/lib/likes.ts
const KEY_PREFIX = VERCEL_ENV && VERCEL_ENV !== 'production' ? `${VERCEL_ENV}:` : '';

要證明它有效,不需要存取資料庫。在預覽網址上對文章按下喜歡,接著讀取正式環境的計數並確認它沒有改變。這是只用兩個端點完成的黑箱檢查,測的是真正重要的那件事,而不是實作它的那把鍵。

讓 221 行變成二進位檔的 NUL 位元組

合併前的一次程式碼審查,找到了型別檢查永遠不會發現的東西。

src/lib/likes.ts | Bin 0 -> 8189 bytes

代理在組出雜湊輸入的樣板裡寫進了一個實際的 U+0000,之後又把檔案讀了兩次都沒看到。它顯示起來就是一個空格。git 的二進位判斷正是看這個位元組,因此本站唯一的伺服器端模組差一點就以「Binary file not shown」的樣子出現在 pull request 裡,而且從此對 git diffgit blamegrep 永遠不可見。

最後那一項是安靜的部分。檔案就在那裡而且內容包含它,grep -rn "LikeState" src/ 卻什麼也沒回傳。修正的行為完全相同,寬度只有一個字元:

src/lib/likes.ts
return createHash('sha256').update(`${address}\0${salt}`).digest('base64url').slice(0, 32);

在那之後,git diff --stat 報的是 221 +++++,而不是 Bin

冒煙測試能通過之前的三個錯誤

寫這個端點花了一個下午。證明它能用花了四個 PR。

差距在於 pnpm checkpnpm build 從來不會呼叫這條路由。它們證明的是能編譯、能打包,於是有兩件事一直未經證實,直到有東西真的打到已部署的函式為止:在 Vercel 函式裡執行的 getCollection('blog'),以及 Upstash 用戶端確實連得到 Redis。我沒有打開預覽部署就合併了這個功能,所以這兩者的第一次真實執行都發生在線上網站。它是成功了,但那是運氣,不是證據。

所以下一個變更是一個在每次部署時對端點做冒煙測試的工作流程。它失敗了三次。

第一個錯誤出在比對樣式。 它用 ^status: *published 尋找已發布的文章,但 frontmatter 實際上寫的是含引號的 status: 'published'。它什麼都沒比對到,走完整個迴圈,然後回報:

No published article found to test against.

這句訊息描述的是內容不存在。真正的問題是壞掉的比對樣式,而一句錯的訊息比一個錯的結果更昂貴。

第二個是觸發條件。 deployment_status 會在儲存庫上每一個 GitHub Deployment 觸發,而這裡有兩種:Vercel 建立的,以及既有 CI 工作裡 environment: production 區塊建立的。對後者而言,target_url 是一個 Actions 的工作頁面。這個工作流程盡責地對 github.com 發了 curl。

建立者 target_url
vercel[bot] 該部署的網站網址
environment: production 區塊 https://github.com/…/actions/runs/…/job/…

現在它是依建立者判斷,而不是依網址的形狀,所以日後加上自訂網域也不會讓它悄悄失效。

第三個是驗證。 vercel deploy 印出的是該次部署專屬的網址,而不是正式環境的別名,而部署專屬網址受 Deployment Protection 保護。請求回來的是導向登入頁的轉址,而該步驟試著把它的內容當成計數來解析:

Unexpected body from the like endpoint: Redirecting...

修正方式是一組 Protection Bypass for Automation 密鑰,以 x-vercel-protection-bypass 標頭送出。而且比起別名,測試部署網址本來就是更好的檢查,因為它打到的正是剛建置出來的產物,不必等待別名切換過去。

共通的線索值得寫下來。GitHub 只會為位於預設分支上的工作流程檔案派送 deployment_status,所以這個工作流程在合併之前根本跑不起來。那三個錯誤每一個都得先送出去,才有機會被發現。阻止第四輪的,是在合併修正之前用那組略過密鑰把每一項檢查手動跑過一遍。

通過之後,執行紀錄裡的五行承載了前面幾節所主張的一切:

GET /api/likes/a2a-mcp-... -> {"count":0}
unknown slug -> 404
POST like -> {"count":1,"liked":true}
POST unlike -> {"count":0,"liked":false}
production before={"count":0} after={"count":0}

最後一行就是鍵空間分離的證明,而且會在日後每一個 PR 上自動執行。

圖表檢查怪罪了一台正在回應的伺服器

第四句錯的訊息是在寫這篇文章的過程中冒出來的,位置在這個部落格自己的工具裡,而不是喜歡計數器碰過的任何地方。pnpm figures:fit 會描繪每一張圖表,並把標籤對照它們的方框量測。它開始在整批掃描時失敗,但單篇文章的執行卻能通過:

Could not reach http://localhost:4321. Start the dev server first: pnpm dev

開發伺服器一直是開著的,而且整段期間都在同一個埠上回應 curl診斷在時間裡,不在訊息裡:第一頁在 639 毫秒穩定下來,第二頁則在完全沒有任何請求在途的情況下,剛好於 30,000 毫秒逾時。這個檢查是用 Playwright 的 waitUntil: 'networkidle' 導覽的,而 Vite 會一直開著 HMR 的 WebSocket,因此它等待的「連線數為零」狀態不會可靠地出現。包在導覽外層的 catch 於是把每一種失敗都回報成伺服器沒開。

改成 waitUntil: 'load' 就修好了,現在整批掃描量測 60 頁大約只要三秒。值得留下來的是接下來差點出錯的地方。networkidle 一直還做著第二件沒被寫出來的工作:等待那個由本站自行提供、且有 28 處圖表標籤是依它量測的等寬 Web 字型。若是把它拿掉而沒有補上明確的 document.fonts.ready,執行不會壞掉,只會讓量測結果默默改變,而那是更昂貴的失敗。

總結

  • 只存在讀者瀏覽器裡的喜歡不是訊號。 它沒有給讀者任何他們原本沒有的東西,也沒有給作者任何東西,這就是願意為真正的計數器付出成本的理由。
  • 只會累加的端點不算計數。 投票紀錄讓數字成為證據,同時也讓取消喜歡成為可能,而那正是讓按鈕能誠實掛上 aria-pressed 的原因。
  • 一條無伺服器路由的成本不只是那條路由。 轉接器搬走了建置產物,而 pagefind --site <missing> 會寫出空索引並以 0 結束。
  • 函式的區域與資料庫的區域是同一個決定。 Upstash 的主要區域在建立後無法變更,而 Vercel 的預設值位在另一個大陸。
  • 原始碼裡的 U+0000 會讓 git 把檔案當成二進位,於是它從 diffblamegrep 中消失,而在編輯器裡看起來完全正常。
  • 只能靠送出去才能測試的工作,就會以壞掉的狀態送出去。 deployment_status 只從預設分支派送,所以每一次修正都得先合併才能檢查。
  • 一個把原因說錯的檢查,比一個乾脆失敗的檢查更昂貴。 這裡有兩次,訊息說的是內容不存在或伺服器沒開,真正的原因卻是什麼都沒比對到的樣式,以及一次逾時的導覽。

參考連結

分享這篇文章