跳至主要內容
版本:最新 (v5.0.x)

延遲接受請求

簡介

Fastify 提供了幾個 hook,適用於各種情況。其中一個是 onReady hook,它適用於在伺服器開始接受新請求「之前」執行任務。但是,並沒有直接的機制來處理您希望伺服器開始接受「特定」請求,並拒絕所有其他請求,至少到某個時間點的情況。

例如,假設您的伺服器需要使用 OAuth 提供者進行驗證才能開始處理請求。為此,它需要參與 OAuth 授權碼流程,這將需要它監聽來自驗證提供者的兩個請求

  1. 授權碼 webhook
  2. 權杖 webhook

在授權流程完成之前,您將無法處理客戶請求。那該怎麼辦?

有幾種解決方案可以實現這種行為。在這裡,我們將介紹其中一種技術,並希望您能夠盡快開始運作!

解決方案

概述

所提出的解決方案是處理此情境和許多類似情境的眾多可能方法之一。它僅依賴於 Fastify,因此不需要花俏的基礎結構技巧或第三方函式庫。

為了簡化事情,我們將不處理精確的 OAuth 流程,而是模擬一種情境,其中需要某些金鑰才能處理請求,並且該金鑰只能透過與外部提供者進行驗證才能在執行階段檢索。

這裡的主要目標是「盡早」拒絕原本會失敗的請求,並提供一些「有意義的上下文」。這對伺服器(分配給註定失敗的任務的資源更少)和客戶端(他們獲得一些有意義的資訊,並且不需要等待很長時間)都很有用。

這將透過將兩個主要功能包裝到自訂外掛程式中來實現

  1. 與提供者進行驗證的機制,使用驗證金鑰(從此以後稱為 magicKey裝飾 fastify 物件
  2. 拒絕原本會失敗的請求的機制

實作

對於這個範例解決方案,我們將使用以下內容

  • node.js v16.14.2
  • npm 8.5.0
  • fastify 4.0.0-rc.1
  • fastify-plugin 3.0.1
  • undici 5.0.0

假設我們一開始設定了以下基本伺服器

const Fastify = require('fastify')

const provider = require('./provider')

const server = Fastify({ logger: true })
const USUAL_WAIT_TIME_MS = 5000

server.get('/ping', function (request, reply) {
reply.send({ error: false, ready: request.server.magicKey !== null })
})

server.post('/webhook', function (request, reply) {
// It's good practice to validate webhook requests really come from
// whoever you expect. This is skipped in this sample for the sake
// of simplicity

const { magicKey } = request.body
request.server.magicKey = magicKey
request.log.info('Ready for customer requests!')

reply.send({ error: false })
})

server.get('/v1*', async function (request, reply) {
try {
const data = await provider.fetchSensitiveData(request.server.magicKey)
return { customer: true, error: false }
} catch (error) {
request.log.error({
error,
message: 'Failed at fetching sensitive data from provider',
})

reply.statusCode = 500
return { customer: null, error: true }
}
})

server.decorate('magicKey')

server.listen({ port: '1234' }, () => {
provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)
.catch((error) => {
server.log.error({
error,
message: 'Got an error while trying to get the magic key!'
})

// Since we won't be able to serve requests, might as well wrap
// things up
server.close(() => process.exit(1))
})
})

我們的程式碼只是設定一個具有一些路由的 Fastify 伺服器

  • 一個 /ping 路由,透過檢查是否已設定 magicKey 來指定服務是否已準備好處理請求
  • 一個 /webhook 端點,供我們的提供者在準備好分享 magicKey 時回覆我們。然後,magicKey 會儲存到先前在 fastify 物件上設定的裝飾器中
  • 一個捕捉所有 /v1* 路由,以模擬客戶發起的請求。這些請求依賴我們擁有有效的 magicKey

provider.js 檔案,模擬外部提供者的操作,如下所示

const { fetch } = require('undici')
const { setTimeout } = require('node:timers/promises')

const MAGIC_KEY = '12345'

const delay = setTimeout

exports.thirdPartyMagicKeyGenerator = async (ms) => {
// Simulate processing delay
await delay(ms)

// Simulate webhook request to our server
const { status } = await fetch(
'https://127.0.0.1:1234/webhook',
{
body: JSON.stringify({ magicKey: MAGIC_KEY }),
method: 'POST',
headers: {
'content-type': 'application/json',
},
},
)

if (status !== 200) {
throw new Error('Failed to fetch magic key')
}
}

exports.fetchSensitiveData = async (key) => {
// Simulate processing delay
await delay(700)
const data = { sensitive: true }

if (key === MAGIC_KEY) {
return data
}

throw new Error('Invalid key')
}

這裡最重要的程式碼片段是 thirdPartyMagicKeyGenerator 函式,它將等待 5 秒鐘,然後向我們的 /webhook 端點發出 POST 請求。

當我們的伺服器啟動時,我們開始監聽新的連線,而沒有設定我們的 magicKey。在我們收到來自外部提供者的 webhook 請求之前(在本範例中,我們模擬 5 秒的延遲),我們在 /v1* 路徑下的所有請求(客戶請求)都將失敗。更糟糕的是:它們會在我們使用無效的金鑰聯絡提供者並收到來自他們的錯誤之後失敗。這對我們和我們的客戶來說都是浪費時間和資源。根據我們正在執行的應用程式類型以及我們預期的請求速率,此延遲是不可接受的,或者至少非常令人惱火。

當然,這可以透過在 /v1* 處理常式中點擊提供者之前檢查是否已設定 magicKey 來簡單地緩解。當然,但這會導致程式碼膨脹。想像一下,我們有數十個不同的路由,具有不同的控制器,需要該金鑰。我們是否應該重複將該檢查新增到所有路由中?這很容易出錯,而且還有更優雅的解決方案。

為了整體改進此設定,我們將建立一個 Plugin,它將完全負責確保我們既

  • 在我們準備好之前不接受原本會失敗的請求
  • 確保我們盡快聯絡我們的提供者

這樣,我們將確保我們關於此特定「業務規則」的所有設定都放在一個實體中,而不是分散在我們的整個程式碼庫中。

透過變更來改進此行為,程式碼將如下所示

index.js
const Fastify = require('fastify')

const customerRoutes = require('./customer-routes')
const { setup, delay } = require('./delay-incoming-requests')

const server = new Fastify({ logger: true })

server.register(setup)

// Non-blocked URL
server.get('/ping', function (request, reply) {
reply.send({ error: false, ready: request.server.magicKey !== null })
})

// Webhook to handle the provider's response - also non-blocked
server.post('/webhook', function (request, reply) {
// It's good practice to validate webhook requests really come from
// whoever you expect. This is skipped in this sample for the sake
// of simplicity

const { magicKey } = request.body
request.server.magicKey = magicKey
request.log.info('Ready for customer requests!')

reply.send({ error: false })
})

// Blocked URLs
// Mind we're building a new plugin by calling the `delay` factory with our
// customerRoutes plugin
server.register(delay(customerRoutes), { prefix: '/v1' })

server.listen({ port: '1234' })
provider.js
const { fetch } = require('undici')
const { setTimeout } = require('node:timers/promises')

const MAGIC_KEY = '12345'

const delay = setTimeout

exports.thirdPartyMagicKeyGenerator = async (ms) => {
// Simulate processing delay
await delay(ms)

// Simulate webhook request to our server
const { status } = await fetch(
'https://127.0.0.1:1234/webhook',
{
body: JSON.stringify({ magicKey: MAGIC_KEY }),
method: 'POST',
headers: {
'content-type': 'application/json',
},
},
)

if (status !== 200) {
throw new Error('Failed to fetch magic key')
}
}

exports.fetchSensitiveData = async (key) => {
// Simulate processing delay
await delay(700)
const data = { sensitive: true }

if (key === MAGIC_KEY) {
return data
}

throw new Error('Invalid key')
}
delay-incoming-requests.js
const fp = require('fastify-plugin')

const provider = require('./provider')

const USUAL_WAIT_TIME_MS = 5000

async function setup(fastify) {
// As soon as we're listening for requests, let's work our magic
fastify.server.on('listening', doMagic)

// Set up the placeholder for the magicKey
fastify.decorate('magicKey')

// Our magic -- important to make sure errors are handled. Beware of async
// functions outside `try/catch` blocks
// If an error is thrown at this point and not captured it'll crash the
// application
function doMagic() {
fastify.log.info('Doing magic!')

provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)
.catch((error) => {
fastify.log.error({
error,
message: 'Got an error while trying to get the magic key!'
})

// Since we won't be able to serve requests, might as well wrap
// things up
fastify.close(() => process.exit(1))
})
}
}

const delay = (routes) =>
function (fastify, opts, done) {
// Make sure customer requests won't be accepted if the magicKey is not
// available
fastify.addHook('onRequest', function (request, reply, next) {
if (!request.server.magicKey) {
reply.statusCode = 503
reply.header('Retry-After', USUAL_WAIT_TIME_MS)
reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })
}

next()
})

// Register to-be-delayed routes
fastify.register(routes, opts)

done()
}

module.exports = {
setup: fp(setup),
delay,
}
customer-routes.js
const fp = require('fastify-plugin')

const provider = require('./provider')

module.exports = fp(async function (fastify) {
fastify.get('*', async function (request ,reply) {
try {
const data = await provider.fetchSensitiveData(request.server.magicKey)
return { customer: true, error: false }
} catch (error) {
request.log.error({
error,
message: 'Failed at fetching sensitive data from provider',
})

reply.statusCode = 500
return { customer: null, error: true }
}
})
})

先前存在的檔案有一個非常具體的變更值得一提:先前我們使用 server.listen 回呼來開始與外部提供者的驗證流程,並且我們在初始化伺服器之前裝飾了 server 物件。這使得我們的伺服器初始化設定中包含不必要的程式碼,並且與啟動 Fastify 伺服器沒有太大關係。這是一個業務邏輯,在程式碼庫中沒有其特定的位置。

現在,我們已在 delay-incoming-requests.js 檔案中實作了 delayIncomingRequests 外掛程式。實際上,這是一個模組,分為兩個不同的外掛程式,它們將建構成一個單一的用例。這是我們運作的核心。讓我們逐步了解外掛程式的功能

設定

setup 外掛程式負責確保我們盡快聯絡我們的提供者,並將 magicKey 儲存在所有處理常式都可用的位置。

  fastify.server.on('listening', doMagic)

一旦伺服器開始監聽(與向 server.listen 的回呼函式新增一段程式碼非常相似的行為),就會發出一個 listening 事件(如需更多資訊,請參閱 https://node.dev.org.tw/api/net.html#event-listening)。我們使用它來盡快聯絡我們的提供者,並使用 doMagic 函式。

  fastify.decorate('magicKey')

magicKey 裝飾也是外掛程式的一部分。我們使用預留位置初始化它,等待檢索有效的數值。

延遲

delay 本身不是一個外掛程式。它實際上是一個外掛程式「工廠」。它期望一個具有 routes 的 Fastify 外掛程式,並匯出實際的外掛程式,該外掛程式將使用 onRequest hook 包裹這些路由,以確保在我們準備好之前不會處理任何請求。

const delay = (routes) =>
function (fastify, opts, done) {
// Make sure customer requests won't be accepted if the magicKey is not
// available
fastify.addHook('onRequest', function (request, reply, next) {
if (!request.server.magicKey) {
reply.statusCode = 503
reply.header('Retry-After', USUAL_WAIT_TIME_MS)
reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })
}

next()
})

// Register to-be-delayed routes
fastify.register(routes, opts)

done()
}

我們沒有更新每個可能使用 magicKey 的控制器,而是簡單地確保與客戶請求相關的任何路由都不會在我們準備好之前處理。而且還有更多:我們會「快速」失敗,並且有可能向客戶提供有意義的資訊,例如他們應該等待多長時間才能重試請求。更進一步,透過發出 503 狀態碼,我們向我們的基礎結構元件(即負載平衡器)發出訊號,表示我們仍未準備好接受傳入的請求,並且他們應該將流量重新導向到其他可用的執行個體,以及我們估計多久可以解決。所有這些都在幾行簡單的程式碼中完成!

值得注意的是,我們沒有在 delay 工廠中使用 fastify-plugin 包裝器。這是因為我們希望 onRequest hook 僅在該特定範圍內設定,而不是在呼叫它的範圍內設定(在我們的案例中,是 index.js 中定義的主要 server 物件)。fastify-plugin 設定了 skip-override 隱藏屬性,其作用是使我們對 fastify 物件所做的任何變更都可供上層範圍使用。這也是為什麼我們將它與 customerRoutes 外掛程式一起使用的原因:我們希望這些路由可供其呼叫範圍(即 delay 外掛程式)使用。如需有關此主題的更多資訊,請參閱 外掛程式

讓我們看看它在實際操作中的行為。如果我們使用 node index.js 啟動伺服器並發出一些請求來測試,我們會看到以下日誌(刪除了一些膨脹以簡化操作)

{"time":1650063793316,"msg":"Doing magic!"}
{"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}
{"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}
{"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}
{"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}
{"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}
{"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}
{"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}
{"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}
{"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}
{"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}

讓我們關注幾個部分

{"time":1650063793316,"msg":"Doing magic!"}
{"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}

這些是伺服器啟動後我們會看到的初始日誌。我們會在有效時間視窗內盡快聯絡外部提供者(我們無法在伺服器準備好接收連線之前執行此操作)。

在伺服器仍未準備好時,會嘗試一些請求

{"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}
{"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}
{"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}
{"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}

第一個請求 (req-1) 是 GET /v1,它失敗了 (快速 - responseTime 單位是 ms),回傳了 503 狀態碼,以及含有意義的回應資訊。以下是該請求的回應內容

HTTP/1.1 503 Service Unavailable
Connection: keep-alive
Content-Length: 31
Content-Type: application/json; charset=utf-8
Date: Fri, 15 Apr 2022 23:03:15 GMT
Keep-Alive: timeout=5
Retry-After: 5000

{
"error": true,
"retryInMs": 5000
}

接著,我們嘗試發出一個新的請求 (req-2),它是 GET /ping。正如預期的,因為這不是我們要求外掛過濾的請求之一,所以它成功了。這也可以用來告知相關方我們是否已準備好處理請求 (儘管 /ping 更常與活性檢查相關聯,而準備就緒檢查則應由 ready 欄位負責 -- 好奇的讀者可以在這裡找到更多相關資訊)。以下是該請求的回應內容

HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 29
Content-Type: application/json; charset=utf-8
Date: Fri, 15 Apr 2022 23:03:16 GMT
Keep-Alive: timeout=5

{
"error": false,
"ready": false
}

之後還有更多有趣的日誌訊息

{"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}
{"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}
{"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}

這次是我們模擬的外部供應商通知我們身份驗證已順利完成,並告知我們的 magicKey 是什麼。我們將其儲存到我們的 magicKey 修飾器中,並用一條日誌訊息慶祝我們現在已準備好為客戶服務!

{"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}
{"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}

最後,發出了最後一個 GET /v1 請求,而這次它成功了。其回應如下

HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 31
Content-Type: application/json; charset=utf-8
Date: Fri, 15 Apr 2022 23:03:20 GMT
Keep-Alive: timeout=5

{
"customer": true,
"error": false
}

結論

實作細節會因問題而異,但本指南的主要目標是展示一個非常具體的使用案例,這個問題可以在 Fastify 的生態系統中解決。

本指南是一個關於如何使用外掛、修飾器和鉤子來解決延遲服務應用程式中特定請求問題的教學。它不是生產就緒的,因為它保留了本地狀態 (magicKey),並且無法水平擴展 (我們不想過度轟炸我們的供應商,對吧?)。 一種改進的方法是將 magicKey 儲存在其他地方 (也許是快取資料庫?)。

這裡的關鍵字是修飾器鉤子外掛。結合 Fastify 所提供的功能,可以針對各種問題提出非常巧妙且富有創意的解決方案。讓我們發揮創意吧!:)