Skip to Content

延迟接受请求

引言

Fastify 提供了多种 钩子,适用于各种情况。其中一个钩子是 onReady,它可以在服务器开始接受新请求之前执行任务。然而,没有直接的机制来处理希望服务器仅接受 特定 请求并拒绝其他所有请求的情况(至少在某个点之前)。

例如,假设您的服务器需要通过 OAuth 提供商进行身份验证才能开始提供服务。为此,它需要参与 OAuth 授权码流程 ,这要求它监听来自认证提供商的两个请求:

  1. 授权码 webhook
  2. 令牌 webhook

在授权流程完成之前,您将无法处理客户请求。那么该怎么办?

有许多解决方案可以实现这种行为。在这里我们将介绍其中一种技术,并希望您可以尽快解决问题!

解决方案

概览

所提出的解决方案是处理此类场景的众多可能方法之一。它完全依赖于 Fastify,因此不需要任何复杂的基础设施技巧或第三方库。

为了简化问题,我们不会处理具体的 OAuth 流程,而是模拟一个需要某种密钥来服务请求的情景,并且该密钥只能通过在运行时与外部提供者进行身份验证才能获取。

主要目标是尽早拒绝那些原本会失败的请求,并带有有意义的上下文信息。这对服务器(减少分配给注定要失败的任务的资源)和客户端(获得一些有意义的信息,而无需等待很长时间)都有用处。

这将通过在自定义插件中封装两个主要功能来实现:

  1. 与提供者进行身份验证的机制 装饰 fastify 对象以包含身份验证密钥(从现在开始为 magicKey
  2. 拒绝那些原本会失败的请求的机制
- [decorating](../Reference/Decorators.md)

翻译后的锚点链接:

  1. [装饰](../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所提供的功能,可以为各种各样的问题带来非常巧妙且创新的解决方案。让我们发挥创造力吧! :)