延迟接受请求
引言
Fastify 提供了多种 钩子,适用于各种情况。其中一个钩子是 onReady
,它可以在服务器开始接受新请求之前执行任务。然而,没有直接的机制来处理希望服务器仅接受 特定 请求并拒绝其他所有请求的情况(至少在某个点之前)。
例如,假设您的服务器需要通过 OAuth 提供商进行身份验证才能开始提供服务。为此,它需要参与 OAuth 授权码流程 ,这要求它监听来自认证提供商的两个请求:
- 授权码 webhook
- 令牌 webhook
在授权流程完成之前,您将无法处理客户请求。那么该怎么办?
有许多解决方案可以实现这种行为。在这里我们将介绍其中一种技术,并希望您可以尽快解决问题!
解决方案
概览
所提出的解决方案是处理此类场景的众多可能方法之一。它完全依赖于 Fastify,因此不需要任何复杂的基础设施技巧或第三方库。
为了简化问题,我们不会处理具体的 OAuth 流程,而是模拟一个需要某种密钥来服务请求的情景,并且该密钥只能通过在运行时与外部提供者进行身份验证才能获取。
主要目标是尽早拒绝那些原本会失败的请求,并带有有意义的上下文信息。这对服务器(减少分配给注定要失败的任务的资源)和客户端(获得一些有意义的信息,而无需等待很长时间)都有用处。
这将通过在自定义插件中封装两个主要功能来实现:
- 与提供者进行身份验证的机制
装饰
fastify
对象以包含身份验证密钥(从现在开始为magicKey
) - 拒绝那些原本会失败的请求的机制
- [decorating](../Reference/Decorators.md)
翻译后的锚点链接:
[装饰](../Reference/Decorators.md)
实战操作
对于此示例解决方案,我们将使用以下内容:
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) {
// 验证 webhook 请求来自预期来源是良好的实践。在此示例中为了简化而省略了验证步骤
const { magicKey } = request.body
request.server.magicKey = magicKey
request.log.info('准备处理客户请求!')
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: '从提供者获取敏感数据失败',
})
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: '尝试获取 magic key 时发生错误!'
})
// 由于我们无法处理请求,最好结束服务
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) => {
// 模拟处理延迟
await delay(ms)
// 模拟向我们服务器发送webhook请求
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) => {
// 模拟处理延迟
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)
// 非阻塞URL
server.get('/ping', function (request, reply) {
reply.send({ error: false, ready: request.server.magicKey !== null })
})
// 处理提供商响应的Webhook - 同样是非阻塞的
server.post('/webhook', function (request, reply) {
// 验证Webhook请求确实来自预期来源是良好的实践。在此示例中为了简化而省略了验证步骤
const { magicKey } = request.body
request.server.magicKey = magicKey
request.log.info('准备处理客户请求!')
reply.send({ error: false })
})
// 阻塞URL
// 注意我们通过调用`delay`工厂函数并传入customerRoutes插件来构建一个新的插件
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) => {
// 模拟处理延迟
await delay(ms)
// 模拟向我们服务器发送Webhook请求
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('获取magic key失败')
}
}
exports.fetchSensitiveData = async (key) => {
// 模拟处理延迟
await delay(700)
const data = { sensitive: true }
if (key === MAGIC_KEY) {
return data
}
throw new Error('无效的密钥')
}
延迟请求处理.js
const fp = require('fastify-plugin')
const provider = require('./provider')
const USUAL_WAIT_TIME_MS = 5000
async function setup(fastify) {
// 在开始监听请求时,执行我们的魔法操作
fastify.server.on('listening', doMagic)
// 设置魔法键的占位符
fastify.decorate('magicKey')
// 我们的魔法操作 —— 确保错误处理得当。注意异步函数在 `try/catch` 块外可能会导致应用崩溃
function doMagic() {
fastify.log.info('执行魔法!')
provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)
.catch((error) => {
fastify.log.error({
error,
message: '获取魔法键时发生错误!'
})
// 如果无法处理请求,最好关闭应用
fastify.close(() => process.exit(1))
})
}
}
const delay = (routes) =>
function (fastify, opts, done) {
// 确保在没有魔法键的情况下不接受客户请求
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()
})
// 注册需要延迟的路由
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: '从提供者获取敏感数据失败',
})
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://nodejs.org/api/net.html#event-listening)。我们使用该事件尽快调用 doMagic
函数与提供者通信。
fastify.decorate('magicKey')
现在插件中还包括了 magicKey
装饰。我们初始化它为一个占位符,等待获取有效值。
延迟
delay
并不是一个插件本身。它实际上是一个插件工厂。它期望一个包含 routes
的 Fastify 插件,并导出实际的插件,该插件将通过在这些路由上使用 onRequest
钩子来确保在准备好处理请求之前不会有任何请求被处理。
const delay = (routes) =>
function (fastify, opts, done) {
// 确保如果 magicKey 不可用,则不接受客户请求
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()
})
// 注册待延迟的路由
fastify.register(routes, opts)
done()
}
我们不需要更新每个可能使用 magicKey
的控制器,而是确保在一切准备就绪之前,
与客户请求相关的所有路由都不会被提供服务。不仅如此,我们快速失败并有机会向客户提供有意义的信息,
例如他们应该等待多久再重试请求。更进一步地,通过发出一个 503 状态码
,
我们向我们的基础设施组件(特别是负载均衡器)传达了我们尚未准备好处理传入请求的信息,并且它们应该将流量重定向到其他实例(如果可用)。此外,我们还提供了一个 Retry-After
头部,
其中包含客户端应在重试之前等待的毫秒数。
值得注意的是,我们在 delay
工厂中没有使用 fastify-plugin
包装器。这是因为我们希望
onRequest
回调仅在该特定范围内设置,并且不在调用它的范围(在这种情况下是定义于 index.js
中的主要 server
对象)内设置。
fastify-plugin
设置了隐藏属性 skip-override
,这实际上使我们在 fastify
对象上所做的任何更改对上级作用域可用。这也是为什么我们将其与
customerRoutes
插件一起使用的原因:我们希望这些路由在调用它们的范围(即 delay
插件)内可用。
有关此主题的更多信息,请参阅 插件。
让我们看看实际效果如何。如果我们通过运行 node index.js
启动服务器并发出一些请求来测试一下,我们会看到以下日志(为了简化,去除了部分冗余信息):
{"time":1650063793316,"msg":"正在施展魔法!"}
{"time":1650063793316,"msg":"服务器正在监听 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
以毫秒为单位),返回我们的 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
更常与 存活检查 相关,而这种检查应由 就绪检查 负责——好奇的读者可以在此处获取更多关于这些 术语 的信息)。
下面是该请求的响应:
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":"准备接受客户请求!"}
{"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"请求完成"}
这次是我们模拟的外部提供商向我们发送消息,告知我们认证成功,并告诉我们我们的 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":"请求完成"}
最后,一个最终的 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所提供的功能,可以为各种各样的问题带来非常巧妙且创新的解决方案。让我们发挥创造力吧! :)