扩展框架

本指南介绍如何为 AdonisJS 添加自定义功能。你将学到如何:

  • 使用 macros 为框架类添加自定义方法
  • 使用 getters 创建计算属性
  • 通过 TypeScript 声明合并确保类型安全
  • 在应用中组织扩展代码
  • 扩展特定的框架模块,如 Hash、Session 和身份验证

概述

AdonisJS 提供了一个强大的扩展系统,让你无需修改框架源代码即可为框架类添加自定义方法和属性。这意味着你可以为 HttpRequest 类增加自定义验证逻辑,为 HttpResponse 类添加工具方法,或扩展任何其他框架类以满足应用的特定需求。

扩展系统建立在两个核心概念之上:macros(自定义方法)和 getters(计算属性)。两者都在运行时添加,并通过声明合并与 TypeScript 无缝集成,让你在编辑器中获得完整的类型安全和自动补全。

这套相同的扩展 API 也贯穿于 AdonisJS 自己的第一方包中,使其成为构建可复用功能的成熟模式。无论你是为应用添加几个辅助方法,还是构建一个与社区共享的包,扩展系统都提供了一种整洁、类型安全的方式来增强框架。

为什么要扩展框架?

在深入机制之前,让我们先理解你何时以及为何想要扩展框架类。

如果没有扩展,你就需要在整个应用中反复编写相同的逻辑。例如,检查请求是否期望 JSON 响应:

app/controllers/posts_controller.ts
export default class PostsController {
  async index({ request, response }: HttpContext) {
    // Repeated in every action that returns different formats
    const acceptHeader = request.header('accept', '')
    const wantsJSON = acceptHeader.includes('application/json') || 
                      acceptHeader.includes('+json')
    
    if (wantsJSON) {
      return response.json({ posts: [] })
    }
    
    return view.render('posts/index')
  }
}

使用 macro,你只需编写一次这段逻辑,就能在任何地方使用它:

src/extensions.ts
import { HttpRequest } from '@adonisjs/core/http'

/**
 * Check if the request expects a JSON response based on Accept header
 */
HttpRequest.macro('wantsJSON', function (this: HttpRequest) {
  const firstType = this.types()[0]
  if (!firstType) {
    return false
  }
  
  return firstType.includes('/json') || firstType.includes('+json')
})
app/controllers/posts_controller.ts
export default class PostsController {
  async index({ request, response }: HttpContext) {
    if (request.wantsJSON()) {
      return response.json({ posts: [] })
    }
    
    return view.render('posts/index')
  }
}

在以下情况下,扩展是理想之选:

  • 你有在整个应用中复用的、与框架相关的逻辑
  • 你想保持 AdonisJS 流畅的 API 风格
  • 你正在构建一个与框架深度集成的包
  • 你需要带有自动补全支持的类型安全自定义功能

理解 macros 与 getters

在开始添加扩展之前,让我们先厘清 macros 和 getters 是什么,以及各自何时使用。

Macros 是你添加到类中的自定义方法。它们的工作方式与普通方法一样,可以接受参数、执行计算并返回值。当你需要接受输入或执行操作的功能时,请使用 macros。

Getters 是看起来像普通属性的计算属性。它们按需计算,并可选地缓存结果。对于不需要参数的只读派生数据,请使用 getters。

Macros 和 getters 都使用声明合并——一种 TypeScript 特性,它扩展现有的类型定义以包含你的自定义添加内容。这确保你的扩展具有完整的类型安全和自动补全支持。

在底层,AdonisJS 使用 macroable 包实现这一功能。如果你想了解实现细节,可以参阅该包的文档。

创建你的第一个 macro

让我们一步步构建一个简单的 macro。我们将为 HttpRequest 类添加一个方法,用于检查传入请求是否来自移动设备。

  1. 创建扩展文件

    创建一个专门的文件来存放所有框架扩展。这能将你的扩展代码集中组织在一处。

    src/extensions.ts
    // This file contains all framework extensions for your application

    文件可以随意命名,但 extensions.ts 清晰地表明了它的用途。

  2. 导入要扩展的类

    导入你想为其添加功能的框架类。在我们的示例中,将扩展 HttpRequest 类。

    src/extensions.ts
    import { HttpRequest } from '@adonisjs/core/http'
  3. 添加 macro 方法

    使用 macro 方法添加你的自定义功能。该方法会将类实例作为 this 接收,让你可以访问类现有的所有属性和方法。

    src/extensions.ts
    import { HttpRequest } from '@adonisjs/core/http'
    
    HttpRequest.macro('isMobile', function (this: HttpRequest) {
      /**
       * Get the User-Agent header, defaulting to empty string if not present
       */
      const userAgent = this.header('user-agent', '')
      
      /**
       * Check if the User-Agent contains common mobile identifiers
       */
      return /mobile|android|iphone|ipad|phone/i.test(userAgent)
    })

    function (this: HttpRequest) 这一语法很重要,因为它为你提供了正确的 this 上下文。这里不要使用箭头函数,因为它们不会保留 this 绑定。

  4. 添加 TypeScript 类型定义

    使用声明合并将你的新方法告知 TypeScript。将以下内容添加到扩展文件的末尾。

    src/extensions.ts
    declare module '@adonisjs/core/http' {
      interface HttpRequest {
        isMobile(): boolean
      }
    }

    declare module 中的模块路径必须与你使用的导入路径完全一致。接口名称必须与类名完全一致。

  5. 在提供者中加载扩展

    在某个服务提供者的 boot 方法中导入你的扩展文件,以确保应用启动时注册这些扩展。

    providers/app_provider.ts
    export default class AppProvider {
      async boot() {
        await import('../src/extensions.ts')
      }
    }
  6. 使用你的 macro

    现在你的 macro 已在整个应用中可用,并具有完整的类型安全和自动补全。

    app/controllers/home_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    
    export default class HomeController {
      async index({ request, view }: HttpContext) {
        /**
         * TypeScript knows about isMobile() and provides autocomplete
         */
        if (request.isMobile()) {
          return view.render('mobile/home')
        }
        
        return view.render('home')
      }
    }

创建你的第一个 getter

Getters 是像普通属性一样工作但按需计算的计算属性。让我们为 HttpRequest 类添加一个 getter,用于提供请求路径的清理版本。

src/extensions.ts
import { HttpRequest } from '@adonisjs/core/http'

HttpRequest.getter('cleanPath', function (this: HttpRequest) {
  /**
   * Get the current URL path
   */
  const path = this.url()
  
  /**
   * Remove trailing slashes and convert to lowercase
   */
  return path.replace(/\/+$/, '').toLowerCase()
})
src/extensions.ts
declare module '@adonisjs/core/http' {
  interface HttpRequest {
    cleanPath: string  // Note: property, not a method
  }
}

请注意,类型声明与 macros 不同。Getters 是属性而非方法,因此在类型定义中不要包含 ()

你可以像使用普通属性一样使用 getters:

app/middleware/log_middleware.ts
export default class LogMiddleware {
  async handle({ request, logger }: HttpContext, next: NextFn) {
    /**
     * Access the getter like a property, not a method
     */
    logger.info('Request path: %s', request.cleanPath)
    
    await next()
  }
}
Note

Getter 回调不能是异步的,因为 JavaScript getters 在设计上是同步的。如果你需要异步计算,请改用 macro。

单例 getters

默认情况下,getters 每次被访问时都会重新计算其值。对于代价高昂的计算,你可以将 getter 设为单例,它会在首次计算后缓存结果。

src/extensions.ts
import { HttpRequest } from '@adonisjs/core/http'

/**
 * The third parameter (true) makes this a singleton getter
 */
HttpRequest.getter('ipAddress', function (this: HttpRequest) {
  /**
   * Check for proxy headers first, fall back to direct IP
   * This only runs once per request instance
   */
  return this.header('x-forwarded-for') || 
         this.header('x-real-ip') || 
         this.ips()[0] || 
         this.ip()
}, true)
src/extensions.ts
declare module '@adonisjs/core/http' {
  interface HttpRequest {
    ipAddress: string
  }
}

使用单例 getters 时,该函数对类的每个实例只执行一次,并为该实例缓存返回值:

const ip1 = request.ipAddress  // Executes the getter function
const ip2 = request.ipAddress  // Returns cached value, doesn't re-execute
const ip3 = request.ipAddress  // Still returns cached value
Tip

当计算出的值在实例生命周期内不会改变时,请使用单例 getters。例如,请求的 IP 地址在单个 HTTP 请求期间不会改变,因此缓存它是合理的。

不要将单例 getters 用于可能变化的值,例如基于可变状态的计算属性。

何时使用 macros,何时使用 getters

在 macros 和 getters 之间的选择取决于你的用例。这里是一份实用指南。

在需要以下情形时使用 macros:

  • 接受参数
  • 执行带有副作用的操作
  • 根据输入返回不同的值
  • 执行异步操作
src/extensions.ts
/**
 * Macro example: Accepts a role parameter
 */
HttpRequest.macro('hasRole', function (this: HttpRequest, role: string) {
  const user = this.ctx.auth.user
  return user?.role === role
})

// Usage: request.hasRole('admin')

在需要以下情形时使用 getters:

  • 提供计算出的只读属性
  • 从现有属性计算派生数据
  • 缓存代价高昂的计算(使用单例)
  • 保持类属性式的 API
src/extensions.ts
/**
 * Getter example: Computed property with no parameters
 */
HttpRequest.getter('isAuthenticated', function (this: HttpRequest) {
  return this.ctx.auth.isAuthenticated
})

// Usage: request.isAuthenticated

两者可以共存于同一个类中。请根据你想提供的 API 来选择。

理解声明合并

声明合并是 TypeScript 了解你运行时扩展的方式。正确处理它对类型安全至关重要。

declare module 语句中的模块路径必须与你用来导入该类的路径完全一致:

src/extensions.ts
// If you import like this:
import { HttpRequest } from '@adonisjs/core/http'

// You must declare like this (exact same path):
declare module '@adonisjs/core/http' {
  interface HttpRequest {
    isMobile(): boolean
  }
}
Warning

为什么这很重要:TypeScript 使用模块路径来确定要与哪个类型定义合并。

会发生什么:如果路径不匹配,TypeScript 将无法识别你的扩展。即使你的代码运行正确,你也会看到诸如 “Property 'isMobile' does not exist on type 'Request'” 之类的错误。

解决方案:编写声明时,务必复制完全相同的导入路径:

// ✅ Correct: Paths match
import { HttpRequest } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' { ... }

// ❌ Wrong: Paths don't match
import { HttpRequest } from '@adonisjs/core/http'
declare module '@adonisjs/http-server' { ... }

你可以在同一个 declare module 块中声明多个扩展:

src/extensions.ts
declare module '@adonisjs/core/http' {
  interface HttpRequest {
    isMobile(): boolean
    hasRole(role: string): boolean
    cleanPath: string
    ipAddress: string
  }
}

如果你愿意,也可以将它们拆分到多个块中:

src/extensions.ts
declare module '@adonisjs/core/http' {
  interface HttpRequest {
    isMobile(): boolean
  }
}

declare module '@adonisjs/core/http' {
  interface HttpRequest {
    hasRole(role: string): boolean
  }
}

两种方式的效果完全相同。请根据你的组织偏好来选择。

常见错误

以下是开发者在扩展框架时最常遇到的问题及其修复方法。

Tip

错误:为 macros 使用箭头函数

为什么会失败:箭头函数没有自己的 this 绑定,因此你无法访问类实例。

// ❌ Wrong: Arrow function
HttpRequest.macro('isMobile', () => {
  return this.header('user-agent')  // `this` is undefined!
})

// ✅ Correct: Regular function
HttpRequest.macro('isMobile', function (this: HttpRequest) {
  return this.header('user-agent')  // `this` is the Request instance
})
Tip

错误:忘记单例参数默认为 false

会发生什么:即使值不会改变,你的 getter 也会在每次被访问时重新计算。

// This executes the function every single time
HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) {
  return someExpensiveOperation()
})

// Add true for singleton to cache the result
HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) {
  return someExpensiveOperation()
}, true)  // Caches after first access
Tip

错误:把 getters 当作方法来对待

会发生什么:你会遇到错误,因为 getters 是属性,不是函数。

HttpRequest.getter('ipAddress', function (this: HttpRequest) {
  return this.ip()
})

// ❌ Wrong: Calling it like a method
const ip = request.ipAddress()

// ✅ Correct: Accessing it like a property
const ip = request.ipAddress

支持 macros 的类(Macroable classes)

以下框架类支持 macros 和 getters。每一项都包含导入路径和典型用例。

Application

主应用实例。扩展它以添加应用级别的工具。

常见用例:添加自定义环境检查、应用状态 getters,或全局配置访问器。

示例:添加一个 getter 来检查应用是否运行在特定模式下。

import app from '@adonisjs/core/services/app'

app.getter('isProduction', function () {
  return this.inProduction
})

查看源码

HttpRequest

HTTP 请求类。扩展它以添加请求验证或解析逻辑。

常见用例:添加用于检查请求特征、解析自定义头部或验证请求类型的方法。

示例:添加一个方法来检查请求是否为 AJAX 请求。

import { HttpRequest } from '@adonisjs/core/http'

HttpRequest.macro('isAjax', function (this: HttpRequest) {
  return this.header('x-requested-with') === 'XMLHttpRequest'
})

查看源码

HttpResponse

HTTP 响应类。扩展它以添加自定义响应方法或格式化器。

常见用例:添加用于发送格式化响应、设置常用头部或处理特定响应类型的方法。

示例:添加一个用于发送分页 JSON 响应的方法。

import { HttpResponse } from '@adonisjs/core/http'

HttpResponse.macro('paginated', function (this: HttpResponse, data: any, meta: any) {
  return this.json({ data, meta })
})

查看源码

HttpContext

传递给路由处理器和中间件的 HTTP 上下文类。扩展它以添加上下文级别的工具。

常见用例:添加结合请求和响应逻辑的辅助方法,或为常见操作添加快捷方式。

示例:添加一个获取当前用户或失败的方法。

import { HttpContext } from '@adonisjs/core/http'

HttpContext.macro('getCurrentUser', async function (this: HttpContext) {
  return await this.auth.getUserOrFail()
})

查看源码

Route

单个路由实例。扩展它以添加自定义路由配置方法。

常见用例:添加用于应用常见中间件模式、设置路由元数据,或以特定方式配置路由的方法。

示例:添加一个方法将路由标记为需要身份验证。

import { Route } from '@adonisjs/core/http'

Route.macro('protected', function (this: Route) {
  return this.middleware('auth')
})

查看源码

RouteGroup

路由组实例。扩展它以添加自定义的组级别配置。

常见用例:添加用于对一组路由应用常见模式的方法。

示例:添加一个方法为组应用 API 版本控制。

import { RouteGroup } from '@adonisjs/core/http'

RouteGroup.macro('apiVersion', function (this: RouteGroup, version: number) {
  return this.prefix(`/api/v${version}`)
})

查看源码

RouteResource

资源式路由实例。扩展它以自定义资源路由的行为。

常见用例:添加用于自定义创建哪些资源路由,或添加资源特定中间件的方法。

查看源码

BriskRoute

用于简单路由定义的 Brisk(快捷)路由实例。扩展它以添加快捷方式。

常见用例:为快速路由配置添加便捷方法。

查看源码

ExceptionHandler

全局异常处理器。扩展它以添加自定义错误处理方法。

常见用例:添加用于处理特定错误类型或格式化错误响应的方法。

示例:添加一个方法以一致的方式处理验证错误。

import { ExceptionHandler } from '@adonisjs/core/http'

ExceptionHandler.macro('handleValidationError', function (error: any) {
  return this.ctx.response.status(422).json({ errors: error.messages })
})

查看源码

MultipartFile

上传的文件实例。扩展它以添加文件验证或处理方法。

常见用例:添加用于验证文件类型、处理图像或生成缩略图的方法。

示例:添加一个方法来检查文件是否为图像。

import { MultipartFile } from '@adonisjs/core/bodyparser'

MultipartFile.macro('isImage', function (this: MultipartFile) {
  return this.type?.startsWith('image/')
})

查看源码

扩展特定模块

除了 macros 和 getters,许多 AdonisJS 模块还提供专门的扩展 API,用于添加自定义实现。它们是为更复杂的集成而设计的,例如自定义驱动或加载器。

以下模块可以用自定义实现进行扩展:

这些扩展点超越了简单的方法和属性,让你能够将自定义功能深度集成到框架中。

下一步

现在你已了解如何扩展框架,你可以: