實戰範例 005:GitHub Release Notes 自動生成與跨平台社交廣播
簡介與場景
在軟體產品的開發生命週期中,每當發佈新版本 (Release) 時,我們通常需要將技術性較強的 Commit Messages 轉換為用戶友好 (User-friendly) 的 Release Notes,並推送到 Discord、Slack 或 Twitter 等社交平台。 本範例展示了如何構建一個「GitHub 發佈廣播代理」。我們將處理 GitHub Webhook 的 Payload,防禦重複的 Webhook 事件 (Idempotency),並確保在呼叫外部通訊軟體 API 時能夠優雅地處理速率限制 (Rate Limit) 與連線超時。
原始代碼:不具防護的 Webhook 處理器
初階開發者通常會用 Express.js 寫一個簡單的路由,收到請求就直接呼叫外部 API。這樣容易因為 Webhook 的重試機制導致發送多次重複訊息。
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
app.post('/webhook/github', async (req, res) => {
// 沒有驗證 Signature,也沒有處理重複發送
const { action, release } = req.body;
if (action === 'published') {
const message = `🎉 新版本發佈!${release.name}\n${release.body}`;
// 如果 Discord API 暫時卡住,這裡會超時,GitHub 會重試,導致發佈多次
await axios.post('https://discord.com/api/webhooks/YOUR_WEBHOOK_URL', {
content: message
});
}
res.send('OK');
});
app.listen(3000);
開發者與 AI 的對話記錄
Ponytail (極簡主義者): 「GitHub Webhook 一接就通了,為什麼還要寫那麼多複雜的邏輯?代碼越少 Bug 越少。」
Agent Reach (容錯專家): 「網路請求沒有絕對的穩定。GitHub Webhook 要求你在 10 秒內回覆 200 OK,否則它會認為發送失敗並進行重試。你的代碼中,如果 axios.post 到 Discord 需要 15 秒,GitHub 就會重試,最終導致用戶的 Discord 頻道被相同的新版本通知洗版。」
Ponytail: 「了解。所以我應該先把收到的事件存下來,然後立刻回覆 200 OK,接著再慢慢發送通知對吧?」
Agent Reach: 「完全正確!這叫做『異步處理 (Asynchronous Processing)』與『事件冪等性 (Event Idempotency)』。此外,你也缺少了對 GitHub HMAC-SHA256 簽名 (Signature) 的校驗。若有惡意用戶隨意對這個 Endpoint 發送 POST 請求,你的系統就會發出偽造的更新公告。」
重構/優化後的代碼:加入防偽造、去重與異步隊列
重構後的系統加入了加密校驗與簡單的記憶體去重機制,並使用 setTimeout 模擬將任務放入背景異步處理。
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
// 用於記錄已處理的 Release ID (實務上應使用 Redis 設定過期時間)
const processedReleases = new Set();
// 必須使用 raw body 來計算 HMAC
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
function verifySignature(req) {
const signature = req.headers['x-hub-signature-256'];
if (!signature) return false;
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
const digest = 'sha256=' + hmac.update(req.rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}
async function broadcastToDiscord(releaseData, retries = 3) {
const message = `🚀 **新版本發佈: ${releaseData.name}**\n\n${releaseData.body}`;
while (retries > 0) {
try {
await axios.post(process.env.DISCORD_WEBHOOK_URL, { content: message }, { timeout: 5000 });
console.log(`[INFO] 成功廣播 Release: ${releaseData.id}`);
return;
} catch (error) {
retries--;
console.error(`[ERROR] Discord 廣播失敗,剩餘重試次數: ${retries}`);
if (retries === 0) return;
await new Promise(r => setTimeout(r, 2000)); // 退避重試
}
}
}
app.post('/webhook/github', (req, res) => {
// 1. 安全防護:驗證請求合法性
if (!verifySignature(req)) {
console.warn("[WARN] 拒絕未經授權的 Webhook 請求");
return res.status(401).send('Unauthorized');
}
const githubEvent = req.headers['x-github-event'];
if (githubEvent === 'release' && req.body.action === 'published') {
const release = req.body.release;
// 2. 邊界檢查與去重 (Idempotency)
if (processedReleases.has(release.id)) {
console.log(`[INFO] 忽略重複發佈事件: ${release.id}`);
return res.status(200).send('Already processed');
}
processedReleases.add(release.id);
// 3. 異步處理:立刻回覆 200 OK 避免 GitHub Timeout 重試
setTimeout(() => {
broadcastToDiscord(release).catch(e => console.error(e));
}, 0);
}
// 立即回應
res.status(200).send('Accepted');
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[INFO] Webhook server listening on port ${PORT}`);
});
效益分析表格與解讀
| 防護與優化點 | 原始設計 | 重構後設計 (Agent Reach) | 實際效益 |
|---|---|---|---|
| 請求防偽造 (Authentication) | 無驗證,接受所有請求 | x-hub-signature-256 HMAC 校驗 |
防止惡意競爭對手發送假公告,保障社群安全 |
| 網路逾時反應 | 同步等待 Discord API | 立即回覆 200,異步背景發送 | 滿足 GitHub Webhook 的 10 秒回應規範,避免服務降級 |
| 冪等性 (去重) | 每次收到請求都發送 | processedReleases.has() 去重機制 |
根除重試機制帶來的「洗版」災難 |
| API 容錯重試 | 失敗即丟棄 | 在 broadcastToDiscord 實作 Retry |
增加消息送達的可靠性 (Reliability) |
解讀: 本範例說明了 Webhook 服務的基本防禦。透過 Agent Reach 原則,我們不信任外部傳來的任何觸發器(加入了 HMAC 校驗),並且不預設網路通道永遠順暢(加入了異步處理與重試機制)。這才是建構生產環境事件驅動系統 (Event-driven System) 的標準姿態。