リクエストの受付遅延
はじめに
Fastifyは、さまざまな状況で役立ついくつかのフックを提供します。その1つが、サーバーが新しいリクエストを受け付ける直前にタスクを実行するのに役立つonReady
フックです。ただし、サーバーが特定のリクエストの受け入れを開始し、少なくともある時点までは他のすべてのリクエストを拒否するようなシナリオを処理するための直接的なメカニズムはありません。
たとえば、サーバーがリクエストの処理を開始するためにOAuthプロバイダーで認証する必要があるとします。これを行うには、OAuth認証コードフローに関与する必要があり、認証プロバイダーからの2つのリクエストをリッスンする必要があります。
- 認証コードウェブフック
- トークンウェブフック
認証フローが完了するまで、顧客のリクエストを処理することはできません。その場合、どうすればよいでしょうか?
この種の動作を実現するためのいくつかの解決策があります。ここでは、そのような手法の1つを紹介します。うまくいけば、すぐにでも開始できるでしょう!
解決策
概要
提案されている解決策は、このシナリオやそれに類似したシナリオに対処する多くの可能な方法の1つです。これはFastifyのみに依存しているため、凝ったインフラストラクチャのトリックやサードパーティのライブラリは必要ありません。
話を簡単にするために、正確なOAuthフローは扱いません。代わりに、リクエストを処理するためにキーが必要であり、そのキーは外部プロバイダーで認証することでランタイムにのみ取得できるシナリオをシミュレートします。
ここでの主な目標は、可能な限り早く、意味のあるコンテキストで、そうでなければ失敗するリクエストを拒否することです。これは、サーバー(失敗する運命にあるタスクに割り当てられるリソースが少ない)とクライアント(意味のある情報を取得し、長く待つ必要がない)の両方にとって役立ちます。
これは、カスタムプラグインに2つの主要な機能を組み込むことによって実現されます。
- プロバイダーで認証するためのメカニズム(
fastify
オブジェクトを認証キー(ここからmagicKey
)でデコレートする) - そうでなければ失敗するリクエストを拒否するためのメカニズム
実践
このサンプルソリューションでは、以下を使用します。
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サーバーをセットアップするだけです。
magicKey
がセットアップされているかどうかを確認することにより、サービスがリクエストを処理する準備ができているかどうかを指定する/ping
ルート- プロバイダーが
magicKey
を共有する準備ができたときに私たちに連絡するための/webhook
エンドポイント。次に、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://#: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
が設定されていない状態で新しい接続をリッスンし始めます。外部プロバイダーからのウェブフックリクエストを受け取るまで(この例では、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://#: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
プラグインを実装しました。それは実際には、単一のユースケースを構築する2つの異なるプラグインに分割されたモジュールです。それが私たちの運用の頭脳です。プラグインが何をするかを見ていきましょう。
setup
setup
プラグインは、できるだけ早くプロバイダーに連絡し、すべてのハンドラーが利用できる場所にmagicKey
を保存することを確認する責任があります。
fastify.server.on('listening', doMagic)
サーバーがリッスンを開始するとすぐに(server.listen
のコールバック関数にコードを追加するのと非常によく似た動作)、listening
イベントが発行されます(詳細については、https://node.dokyumento.jp/api/net.html#event-listeningを参照してください)。それを利用して、doMagic
関数を使用してできるだけ早くプロバイダーに連絡します。
fastify.decorate('magicKey')
magicKey
のデコレーションもプラグインの一部になりました。有効な値が取得されるのを待って、プレースホルダーで初期化します。
delay
delay
自体はプラグインではありません。実際にはプラグインのファクトリーです。routes
を持つFastifyプラグインを期待し、リクエストの準備が整うまでリクエストが処理されないようにするonRequest
フックでそれらのルートを包み込む実際のプラグインをエクスポートします。
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
フックが、その特定のスコープ内でのみ設定され、それを呼び出したスコープ(この場合は、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
でした。予想どおり、それはプラグインにフィルターするように依頼したリクエストの1つではなかったため、成功しました。これは、ready
フィールドを使用して、リクエストを処理する準備ができているかどうかを関係者に通知する手段としても使用できます(ただし、/ping
は通常、活性チェックに関連付けられており、それは準備チェックの責任となります。好奇心旺盛な読者は、これらの用語に関する詳細情報をこちらで確認できます)。以下は、そのリクエストのレスポンスです。
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
)を保持し、水平方向にスケーラブルではない(プロバイダーを過負荷にしたくないですよね?)ため、本番環境に対応していません。改善策の1つは、magicKey
を他の場所(おそらくキャッシュデータベース?)に保存することです。
ここでのキーワードは、デコレーター、フック、およびプラグインでした。Fastifyが提供するものを組み合わせることで、幅広い問題に対して非常に独創的で創造的なソリューションを生み出すことができます。創造性を発揮しましょう! :)