中间件

本指南介绍 AdonisJS 应用中的中间件。你将学习如何:

  • 使用三个中间件栈(server、router 和 named)
  • 创建自定义中间件来处理横切关注点
  • 在合适的栈中注册中间件
  • 向命名中间件传递参数以实现路由特定逻辑
  • 在中间件构造函数中使用依赖注入
  • 在中间件管道期间修改请求和响应
  • 在中间件内部处理异常
  • 用自定义属性扩展 HttpContext

概述

中间件是在 HTTP 请求到达你的路由处理器之前执行的函数。链中的每个中间件要么通过发送响应来终止请求,要么使用 next 方法将其转发给下一个中间件。

中间件层让你可以将请求期间必须运行的逻辑封装到专门的、可复用的函数或类中。与其用解析请求体、验证用户身份或记录请求等重复逻辑来使控制器变得杂乱,不如将这些职责转移到专门的中间件中。

你的应用处理的每个 HTTP 请求都会经过中间件管道,因此理解中间件的工作原理以及如何有效组织它们至关重要。

中间件栈

AdonisJS 将中间件分为三类,称为栈(stack)。每个栈服务于不同的目的,并在请求生命周期的不同时间点执行。

Server 中间件栈

  • 每个 HTTP 请求执行,即使没有匹配到路由
  • 在路由器尝试查找匹配路由之前运行
  • 适用场景:日志记录、CORS、安全请求头

Router 中间件栈

  • 仅在找到匹配路由时执行
  • 在路由匹配之后、但在命名中间件和处理器之前运行
  • 适用场景:加载共享数据、解析请求体

Named 中间件集合

  • 显式应用到单个路由或路由分组
  • 可以接受参数以实现每路由定制
  • 适用场景:基于角色的授权、路由特定的限流、功能开关

创建并使用中间件

让我们一步步创建一个完整的日志记录中间件,用于跟踪请求的耗时。我们将生成中间件文件、实现日志逻辑,并将其注册为在所有请求上运行。

  1. 生成中间件

    使用 make:middleware 命令创建一个新的中间件。该命令会在 app/middleware 目录中生成一个带有脚手架的中间件类。

    node ace make:middleware LogRequests
    # CREATE: app/middleware/log_requests_middleware.ts

    生成的中间件包含一个基础类结构,其中有一个 handle 方法,我们将在其中添加日志逻辑:

    app/middleware/log_requests_middleware.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import type { NextFn } from '@adonisjs/core/types/http'
    
    export default class LogRequestsMiddleware {
      async handle(ctx: HttpContext, next: NextFn) {
        /**
         * 在请求处理器之前运行的逻辑
         */
        await next()
        /**
         * 在请求处理器之后运行的逻辑
         */
      }
    }
  2. 实现日志逻辑

    现在让我们实现实际的日志功能。我们将通过在调用 next() 之前捕获开始时间,然后在响应就绪后计算耗时来跟踪每个请求花费的时间。

    app/middleware/log_requests_middleware.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import type { NextFn } from '@adonisjs/core/types/http'
    import string from '@adonisjs/core/helpers/string'
    
    export default class LogRequestsMiddleware {
      async handle({ request, response, logger }: HttpContext, next: NextFn) {
        /**
         * 在调用 next() 之前捕获开始时间。
         * 这发生在下游阶段。
         */
        const startTime = process.hrtime()
        
        /**
         * 调用 next() 以执行剩余的中间件和路由处理器。
         * await 确保我们等待整个链完成。
         */
        await next()
        
        /**
         * next() 完成后,我们进入上游阶段。
         * 响应已经就绪,因此我们可以记录完成详情。
         */
        const endTime = process.hrtime(startTime)
        const responseStatus = response.getStatus()
        const uri = request.url()
        const method = request.method()
        
        logger.info(`${method} ${uri}: ${responseStatus} (${string.prettyHrTime(endTime)})`)
      }
    }
  3. 注册中间件

    最后,让我们将日志中间件注册到 server 中间件栈中,使其在每个请求上运行。我们会将它注册为第一个中间件,以便可以精确计时所有请求。

    Server 中间件使用懒导入在 start/kernel.ts 文件中注册,它们按注册顺序执行。

    start/kernel.ts
    import router from '@adonisjs/core/services/router'
    import server from '@adonisjs/core/services/server'
    
    server.use([
      () => import('#middleware/log_requests_middleware'), 
      () => import('#middleware/container_bindings_middleware'),
      () => import('#middleware/force_json_response_middleware'),
    ])
    
    router.use([
      () => import('@adonisjs/core/bodyparser_middleware'),
    ])

带参数的命名中间件

命名中间件通过允许你将其有选择地应用到特定路由并传递参数来定制其行为,从而提供了灵活性。让我们构建一个检查用户权限的授权中间件,使用名称注册它,并将其应用到受保护的路由。

  1. 创建授权中间件

    我们将创建一个中间件,检查已认证用户是否具有访问路由所需的角色或权限。命名中间件可以接受第三个参数作为选项,使其可以按路由配置。

    app/middleware/authorize_request_middleware.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import type { NextFn } from '@adonisjs/core/types/http'
    
    type AuthorizationOptions = 
      | { permissions: string[] }
      | { role: string }
    
    export default class AuthorizeRequestMiddleware {
      /**
       * 第三个参数 'options' 包含将此中间件应用到路由时
       * 指定的授权要求。
       */
      async handle({ auth, response }: HttpContext, next: NextFn, options: AuthorizationOptions) {
        /**
         * 获取已认证用户,否则抛出异常
         */
        const user = auth.getUserOrFail()
        
        /**
         * 检查用户是否具有所需的角色
         */
        if ('role' in options && user.role !== options.role) {
          return response.unauthorized('Not authorized to access this route')
        }
        
        /**
         * 检查用户是否具有所有必需的权限
         */
        if ('permissions' in options) {
          const hasPermission = options.permissions.every(permission => 
            user.permissions.includes(permission)
          )
          
          if (!hasPermission) {
            return response.unauthorized('Not authorized to access this route')
          }
        }
        
        /**
         * 用户已获授权,继续进入下一个中间件或处理器
         */
        await next()
      }
    }
  2. 注册命名中间件

    现在让我们将授权中间件以 authorize 这个名称注册到 start/kernel.ts 文件中。导出中间件集合可以在路由中使用它时获得完整的 TypeScript 类型安全。

    start/kernel.ts
    import router from '@adonisjs/core/services/router'
    
    export const middleware = router.named({
      authorize: () => import('#middleware/authorize_request_middleware'),
    })
  3. 将中间件应用到路由

    最后,让我们将 authorize 中间件应用到特定路由。从 start/kernel.ts 导入中间件集合,即可为选项参数获得完整的 TypeScript 自动补全和类型检查。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    
    router
      .get('/admin/reports', async () => {
        return { message: 'Admin reports' }
      })
      .use(middleware.authorize({ role: 'admin' }))
    
    router
      .post('/posts', async () => {
        return { message: 'Post created' }
      })
      .use(middleware.authorize({ permissions: ['posts.create'] }))

    你的授权中间件现在正通过类型安全、可配置的权限检查来保护路由!

中间件中的依赖注入

中间件类使用 IoC 容器实例化,允许你将依赖直接注入到中间件构造函数中。依赖只能通过构造函数注入,而不能作为方法参数。容器在创建中间件实例时会自动解析并注入你的依赖。

另请参阅: 依赖注入指南

app/middleware/rate_limit_middleware.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import RateLimitService from '#services/rate_limit_service'

@inject() 
export default class RateLimitMiddleware {
  /**
   * IoC 容器会自动创建 RateLimitService 的实例
   * 并将其注入到这里。
   */
  constructor(protected rateLimitService: RateLimitService) {
  }

  async handle({ request, response }: HttpContext, next: NextFn) {
    const ip = request.ip()
    const isAllowed = await this.rateLimitService.checkLimit(ip)
    
    if (!isAllowed) {
      return response.tooManyRequests('Rate limit exceeded')
    }
    
    await next()
  }
}

理解中间件执行流程

中间件分两个阶段执行:下游阶段上游阶段。理解这个流程对于知道将逻辑放置在中间件中的何处至关重要。

下游阶段发生在 await next() 调用之前。在此阶段,请求按顺序流经每个中间件。

下游阶段(请求 →)

  Server 中间件


  Router 中间件


  Named 中间件


   路由处理器

上游阶段发生在 await next() 调用之后。在此阶段,响应按相反顺序流经中间件返回。

上游阶段(响应 ←)

  Server 中间件


  Router 中间件


  Named 中间件


   路由处理器

这种两阶段执行允许中间件在路由处理器运行之前和之后都执行逻辑。日志中间件示例通过在下游阶段捕获开始时间、在上游阶段计算耗时,演示了这种模式。

修改响应

中间件可以在下游和上游两个阶段修改响应。由于响应对象是可变的,你在中间件中所做的更改会影响发送给客户端的最终响应。

在上游阶段添加请求头

一种常见模式是在路由处理器完成后向响应添加请求头。将请求头逻辑放在 await next() 之后,可以确保处理器在修改响应之前已完整构建好它。

app/middleware/add_headers_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class AddHeadersMiddleware {
  async handle({ response }: HttpContext, next: NextFn) {
    /**
     * 等待处理器构建响应
     */
    await next()
    
    /**
     * 在响应就绪后添加自定义请求头
     */
    response.header('X-Powered-By', 'AdonisJS')
    response.header('X-Response-Time', Date.now().toString())
  }
}

转换响应体

你也可以在上游阶段通过访问并修改响应体来转换中间件中的响应体。这个中间件将所有响应体包装为带有元数据的统一格式。

app/middleware/wrap_response_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class WrapResponseMiddleware {
  async handle({ response }: HttpContext, next: NextFn) {
    /**
     * 等待处理器生成响应
     */
    await next()
    
    /**
     * 将原始响应包装进一个标准信封中
     */
    const body = response.getBody()
    
    response.send({
      success: true,
      data: body,
      timestamp: new Date().toISOString()
    })
  }
}

异常处理

当中间件抛出异常时,AdonisJS 的全局异常处理器会像捕获路由处理器抛出的异常一样捕获并处理它。上游流程照常继续,这意味着任何已经在下游阶段执行过的中间件仍会执行它们的上游代码。

另请参阅: 异常处理指南

app/middleware/validate_api_key_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { errors } from '@adonisjs/core'

export default class ValidateApiKeyMiddleware {
  async handle({ request }: HttpContext, next: NextFn) {
    const apiKey = request.header('X-API-Key')
    
    if (!apiKey) {
      /**
       * 抛出异常会终止请求。
       * 全局异常处理器会捕获并处理它。
       */
      throw new errors.E_UNAUTHORIZED_ACCESS('API key is required')
    }
    
    await next()
  }
}

条件化中间件执行

AdonisJS 不提供在运行时条件化注册或应用中间件的方式。不过,中间件可以使用配置文件来决定在运行时是否应该为当前请求执行。这种模式让你无需更改中间件注册即可通过环境变量或配置文件控制中间件的行为。

config/features.ts
export default {
  enableRateLimit: env.get('ENABLE_RATE_LIMIT', true),
}
app/middleware/rate_limit_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import featuresConfig from '#config/features'

export default class RateLimitMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    /**
     * 如果在配置中禁用了限流,则跳过。
     * 立即调用 next() 使该中间件成为空操作。
     */
    if (!featuresConfig.enableRateLimit) {
      return next()
    }
    
    /**
     * 启用时限流逻辑
     */
    // ... 限流检查
    
    await next()
  }
}

扩展 HttpContext

中间件可以向 HttpContext 对象添加自定义属性,以便与下游中间件和路由处理器共享数据。然而,这需要 TypeScript 模块扩展,以确保这些属性在类型层面上出现。

向上下文添加属性

app/middleware/detect_tenant_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class DetectTenantMiddleware {
  async #detectTenant(ctx: HttpContext) {
    // ... 租户检测逻辑
    return { id: 1, name: 'Acme Corp' }
  }

  async handle(ctx: HttpContext, next: NextFn) {
    /**
     * 从子域名、请求头或数据库检测租户
     */
    const tenant = await this.#detectTenant(ctx)
    
    /**
     * 将租户添加到上下文,供下游中间件和
     * 路由处理器使用
     */
    ctx.tenant = tenant
    
    await next()
  }
}

扩展 HttpContext 类型

要让 TypeScript 知晓新的 tenant 属性,你必须扩展 HttpContext 接口。扩展之后,TypeScript 会在你所有中间件和路由处理器中识别 ctx.tenant

Warning

当你扩展 HttpContext 接口时,类型更改是全局的。但是,如果你通过只运行在特定路由上的命名中间件添加属性,这些属性在未执行该中间件的路由上运行时将不存在。

只在你的应用中广泛运行的 server 或 router 中间件中扩展 HttpContext

types/http.ts
declare module '@adonisjs/core/http' {
  interface HttpContext {
    tenant: {
      id: number
      name: string
    }
  }
}