延迟接受请求
简介
Fastify 提供了多个对各种情况有用的钩子。其中之一是onReady
钩子,它可用于在服务器开始接受新请求之前执行任务。但是,没有直接的机制来处理您希望服务器开始接受特定请求并拒绝所有其他请求(至少在某个时间点之前)的场景。
例如,假设您的服务器需要使用 OAuth 提供商进行身份验证才能开始提供服务。为此,它需要参与OAuth 授权码流程,这需要它监听来自身份验证提供商的两个请求
- 授权码 Webhook
- 令牌 Webhook
在授权流程完成之前,您将无法提供客户请求。那么该怎么办呢?
有几种解决方案可以实现这种行为。在这里,我们将介绍其中一种技术,希望您能够尽快上手!
解决方案
概述
提出的解决方案是处理此类场景以及类似场景的众多可能方法之一。它仅依赖于 Fastify,因此无需任何花哨的基础设施技巧或第三方库。
为了简化问题,我们将不会处理精确的 OAuth 流程,而是模拟一个场景,其中需要某个密钥才能提供服务,并且该密钥只能在运行时通过向外部提供商进行身份验证来检索。
这里的首要目标是尽早拒绝原本会失败的请求,并提供一些有意义的上下文。这对于服务器(为注定失败的任务分配更少的资源)和客户端(他们获得一些有意义的信息,并且无需长时间等待)都很有用。
这将通过将两个主要功能包装到自定义插件中来实现
- 使用外部提供商进行身份验证的机制 装饰
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 服务器,并带有一些路由
- 一个
/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(
'http://localhost: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
来简单地缓解这种情况。当然,但这会导致代码膨胀。想象一下,我们有数十个不同的路由,不同的控制器,都需要该密钥。我们应该反复将此检查添加到所有这些路由中吗?这很容易出错,并且有更优雅的解决方案。
为了整体改进此设置,我们将创建一个插件
,它将完全负责确保我们同时
- 在准备好之前不接受原本会失败的请求
- 确保我们尽快联系我们的提供商
这样,我们将确保与该特定业务规则相关的所有设置都放置在一个实体上,而不是散布在整个代码库中。
对代码进行更改以改进此行为后,代码将如下所示
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(
'http://localhost: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.org.cn/api/net.html#event-listening)。我们使用它来尽快联系我们的提供商,并使用doMagic
函数。
fastify.decorate('magicKey')
magicKey
装饰器现在也是插件的一部分。我们使用占位符初始化它,等待检索到有效值。
延迟
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
请求。正如预期的那样,由于这不是我们要求插件过滤的请求之一,因此它成功了。这也可以作为一种方式来告知感兴趣的方我们是否已准备好服务请求(尽管/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 提供的功能,可以针对各种问题创造出非常巧妙和创新的解决方案。让我们发挥创意吧!:)