Skip to content

依赖注入

每个 AdonisJS 应用程序的核心都是一个 IoC 容器,它可以构造类并几乎零配置地解析依赖项。

IoC 容器服务于以下两个主要用例。

  • 为第一方和第三方包提供 API 来注册和从容器解析绑定(稍后会详细介绍绑定)。
  • 自动解析并将依赖项注入到类构造函数或类方法。

让我们从将依赖项注入到类开始。

基本示例

自动依赖注入依赖于 TypeScript 遗留装饰器实现反射元数据 API。

在下面的例子中,我们创建一个 EchoService 类并将其实例注入到 HomeController 类中。你可以通过复制粘贴代码示例来跟随操作。

步骤 1. 创建 Service 类

首先在 app/services 文件夹中创建 EchoService 类。

ts
// title: app/services/echo_service.ts
export default class EchoService {
  respond() {
    return 'hello'
  }
}

步骤 2. 在控制器中注入服务

app/controllers 文件夹中创建一个新的 HTTP 控制器。或者,你可以使用 node ace make:controller home 命令。

在控制器文件中导入 EchoService 并将其作为构造函数依赖项接受。

ts
// title: app/controllers/home_controller.ts
import EchoService from '#services/echo_service'

export default class HomeController {
  constructor(protected echo: EchoService) {
  }
  
  handle() {
    return this.echo.respond()
  }
}

步骤 3. 使用 inject 装饰器

为了使自动依赖解析工作,我们必须在 HomeController 类上使用 @inject 装饰器。

ts
import EchoService from '#services/echo_service'
import { inject } from '@adonisjs/core'

@inject()
export default class HomeController {
  constructor(protected echo: EchoService) {
  }
  
  handle() {
    return this.echo.respond()
  }
}

就是这样!你现在可以将 HomeController 类绑定到路由,它将自动接收 EchoService 类的实例。

结论

你可以将 @inject 装饰器想象成一个间谍,它查看类构造函数或方法依赖项并将其通知给容器。

当 AdonisJS 路由器要求容器构造 HomeController 时,容器已经知道控制器的依赖项。

构建依赖树

目前,EchoService 类没有依赖项,使用容器创建它的实例可能看起来有点过度设计。

让我们更新类构造函数,使其接受 HttpContext 类的实例。

ts
// title: app/services/echo_service.ts
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'

@inject()
export default class EchoService {
  constructor(protected ctx: HttpContext) {
  }

  respond() {
    return `Hello from ${this.ctx.request.url()}`
  }
}

同样,我们必须将间谍(@inject 装饰器)放在 EchoService 类上以检查其依赖项。

就是这样!无需更改控制器中的任何一行代码,你可以重新运行代码,EchoService 类将接收 HttpContext 类的实例。

TIP

使用容器的好处是你可以拥有深度嵌套的依赖项,容器可以为你解析整个树。唯一的条件是使用 @inject 装饰器。

使用方法注入

方法注入用于将依赖项注入到类方法中。为了使方法注入工作,你必须在方法签名之前放置 @inject 装饰器。

让我们继续我们之前的例子,将 EchoService 依赖项从 HomeController 构造函数移动到 handle 方法。

TIP

在控制器中使用方法注入时,请记住第一个参数接收一个固定值(即 HTTP 上下文),其余参数使用容器解析。

ts
// title: app/controllers/home_controller.ts
import EchoService from '#services/echo_service'
import { inject } from '@adonisjs/core'

export default class HomeController {
  @inject()
  handle(ctx, echo: EchoService) {
    return echo.respond()
  }
}

就是这样!这次,EchoService 类实例将被注入到 handle 方法中。

何时使用依赖注入

建议在项目中利用依赖注入,因为 DI 在应用程序的不同部分之间创建松耦合。因此,代码库更容易测试和重构。

但是,你必须小心,不要将依赖注入的想法走向极端而失去其好处。例如:

  • 你不应该将像 lodash 这样的辅助库作为类的依赖项注入。直接导入并使用它。
  • 对于不太可能被交换或替换的组件,你的代码库可能不需要松耦合。例如,你可能更喜欢导入 logger 服务而不是将 Logger 类作为依赖项注入。

直接使用容器

AdonisJS 应用程序中的大多数类,如 ControllersMiddlewareEvent listenersValidatorsMailers,都是使用容器构造的。因此你可以利用 @inject 装饰器进行自动依赖注入。

对于你想使用容器自行构造类实例的情况,可以使用 container.make 方法。

container.make 方法接受类构造函数并在解析其所有依赖项后返回其实例。

ts
import { inject } from '@adonisjs/core'
import app from '@adonisjs/core/services/app'

class EchoService {}

@inject()
class SomeService {
  constructor(public echo: EchoService) {}
}

/**
 * 与创建类的新实例相同,但
 * 将具有自动 DI 的好处
 */
const service = await app.container.make(SomeService)

console.log(service instanceof SomeService)
console.log(service.echo instanceof EchoService)

你可以使用 container.call 方法将依赖项注入到方法中。container.call 方法接受以下参数。

  1. 类的实例。
  2. 要在类实例上运行的方法名称。容器将解析依赖项并将其传递给方法。
  3. 传递给方法的固定参数的可选数组。
ts
class EchoService {}

class SomeService {
  @inject()
  run(echo: EchoService) {
  }
}

const service = await app.container.make(SomeService)

/**
 * Echo 类的实例将被传递给
 * run 方法
 */
await app.container.call(service, 'run')

容器绑定

容器绑定是 IoC 容器存在于 AdonisJS 中的主要原因之一。绑定充当你安装的包和应用程序之间的桥梁。

绑定本质上是键值对,键是绑定的唯一标识符,值是返回值的工厂函数。

  • 绑定名称可以是 stringsymbol 或类构造函数。
  • 工厂函数可以是异步的,必须返回一个值。

你可以使用 container.bind 方法注册容器绑定。以下是注册和从容器解析绑定的简单示例。

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

class MyFakeCache {
  get(key: string) {
    return `${key}!`
  }
}

app.container.bind('cache', function () {
  return new MyFakeCache()
})

const cache = await app.container.make('cache')
console.log(cache.get('foo')) // 返回 foo!

何时使用容器绑定?

容器绑定用于特定用例,如注册包导出的单例服务或在自动依赖注入不足时自行构造类实例。

我们建议你不要通过将所有内容注册到容器来使应用程序变得不必要地复杂。相反,在使用容器绑定之前,请在应用程序代码中寻找特定用例。

以下是在框架包中使用容器绑定的一些示例。

  • 在容器中注册 BodyParserMiddleware:由于中间件类需要存储在 config/bodyparser.ts 文件中的配置,自动依赖注入无法工作。在这种情况下,我们通过将其注册为绑定来手动构造中间件类实例。
  • 将 Encryption 服务注册为单例:Encryption 类需要存储在 config/app.ts 文件中的 appKey,因此,我们使用容器绑定作为桥梁,从用户应用程序读取 appKey 并配置 Encryption 类的单例实例。

:::important

容器绑定的概念在 JavaScript 生态系统中并不常用。因此,请随时加入我们的 Discord 社区来澄清你的疑问。

:::

在工厂函数中解析绑定

你可以在绑定工厂函数中从容器解析其他绑定。例如,如果 MyFakeCache 类需要来自 config/cache.ts 文件的配置,你可以按如下方式访问它。

ts
this.app.container.bind('cache', async (resolver) => {
  const configService = await resolver.make('config')
  const cacheConfig = configService.get<any>('cache')

  return new MyFakeCache(cacheConfig)
})

单例

单例是工厂函数只调用一次的绑定,返回值在应用程序的生命周期内被缓存。

你可以使用 container.singleton 方法注册单例绑定。

ts
this.app.container.singleton('cache', async (resolver) => {
  const configService = await resolver.make('config')
  const cacheConfig = configService.get<any>('cache')

  return new MyFakeCache(cacheConfig)
})

绑定值

你可以使用 container.bindValue 方法直接将值绑定到容器。

ts
this.app.container.bindValue('cache', new MyFakeCache())

别名

你可以使用 alias 方法为绑定定义别名。该方法接受别名作为第一个参数,以及对现有绑定或类构造函数的引用作为别名值。

ts
this.app.container.singleton(MyFakeCache, async () => {
  return new MyFakeCache()
})

this.app.container.alias('cache', MyFakeCache)

为绑定定义静态类型

你可以使用 TypeScript 声明合并为绑定定义静态类型信息。

类型作为键值对定义在 ContainerBindings 接口上。

ts
declare module '@adonisjs/core/types' {
  interface ContainerBindings {
    cache: MyFakeCache
  }
}

如果你创建包,可以在服务提供者文件中编写上述代码块。

在你的 AdonisJS 应用程序中,可以在 types/container.ts 文件中编写上述代码块。

创建抽象层

容器允许你为应用程序创建抽象层。你可以为接口定义绑定并将其解析为具体实现。

TIP

当你想将六边形架构(也称为端口和适配器原则)应用于应用程序时,此方法很有用。

由于 TypeScript 接口在运行时不存在,你必须为接口使用抽象类构造函数。

ts
export abstract class PaymentService {
  abstract charge(amount: number): Promise<void>
  abstract refund(amount: number): Promise<void>
}

接下来,你可以创建 PaymentService 接口的具体实现。

ts
import { PaymentService } from '#contracts/payment_service'

export class StripePaymentService implements PaymentService {
  async charge(amount: number) {
    // 使用 Stripe 收费
  }

  async refund(amount: number) {
    // 使用 Stripe 退款
  }
}

现在,你可以在 AppProvider 中将 PaymentService 接口和 StripePaymentService 具体实现注册到容器中。

ts
// title: providers/app_provider.ts
import { PaymentService } from '#contracts/payment_service'

export default class AppProvider {
  async boot() {
    const { StripePaymentService } = await import('#services/stripe_payment_service')
    
    this.app.container.bind(PaymentService, () => {
      return this.app.container.make(StripePaymentService)
    })
  }
}

最后,你可以从容器解析 PaymentService 接口并在应用程序中使用它。

ts
import { PaymentService } from '#contracts/payment_service'

@inject()
export default class PaymentController {
  constructor(private paymentService: PaymentService) {
  }

  async charge() {
    await this.paymentService.charge(100)
    
    // ...
  }
}

在测试期间交换实现

当你依赖容器解析依赖树时,你对该树中的类的控制较少/没有控制。因此,模拟/伪造这些类可能变得更困难。

在下面的例子中,UsersController.index 方法接受 UserService 类的实例,我们使用 @inject 装饰器解析依赖项并将其传递给 index 方法。

ts
import UserService from '#services/user_service'
import { inject } from '@adonisjs/core'

export default class UsersController {
  @inject()
  index(service: UserService) {}
}

假设在测试期间,你不想使用实际的 UserService,因为它会发出外部 HTTP 请求。相反,你想使用一个假实现。

但首先,看看你可能编写的测试 UsersController 的代码。

ts
import UserService from '#services/user_service'

test('get all users', async ({ client }) => {
  const response = await client.get('/users')

  response.assertBody({
    data: [{ id: 1, username: 'virk' }]
  })
})

在上面的测试中,我们通过 HTTP 请求与 UsersController 交互,并且没有直接控制它。

容器提供了一个简单的 API 来用假实现交换类。你可以使用 container.swap 方法定义交换。

container.swap 方法接受你要交换的类构造函数,后跟一个返回替代实现的工厂函数。

ts
import UserService from '#services/user_service'
import app from '@adonisjs/core/services/app'

test('get all users', async ({ client }) => {
  class FakeService extends UserService {
    all() {
      return [{ id: 1, username: 'virk' }]
    }
  }
    
  app.container.swap(UserService, () => {
    return new FakeService()
  })
  
  const response = await client.get('users')
  response.assertBody({
    data: [{ id: 1, username: 'virk' }]
  })
})

一旦定义了交换,容器将使用它而不是实际的类。你可以使用 container.restore 方法恢复原始实现。

ts
app.container.restore(UserService)

// 恢复 UserService 和 PostService
app.container.restoreAll([UserService, PostService])

// 恢复全部
app.container.restoreAll()

上下文依赖项

上下文依赖项允许你定义如何为给定类解析依赖项。例如,你有两个服务依赖于 Drive Disk 类。

ts
import { Disk } from '@adonisjs/drive'

export default class UserService {
  constructor(protected disk: Disk) {}
}
ts
import { Disk } from '@adonisjs/drive'

export default class PostService {
  constructor(protected disk: Disk) {}
}

你希望 UserService 接收带有 GCS 驱动程序的磁盘实例,PostService 接收带有 S3 驱动程序的磁盘实例。你可以使用上下文依赖项来实现这一点。

以下代码必须写在服务提供者的 register 方法中。

ts
import { Disk } from '@adonisjs/drive'
import UserService from '#services/user_service'
import PostService from '#services/post_service'
import { ApplicationService } from '@adonisjs/core/types'

export default class AppProvider {
  constructor(protected app: ApplicationService) {}

  register() {
    this.app.container
      .when(UserService)
      .asksFor(Disk)
      .provide(async (resolver) => {
        const driveManager = await resolver.make('drive')
        return drive.use('gcs')
      })

    this.app.container
      .when(PostService)
      .asksFor(Disk)
      .provide(async (resolver) => {
        const driveManager = await resolver.make('drive')
        return drive.use('s3')
      })
  }
}

容器钩子

你可以使用容器的 resolving 钩子来修改/扩展 container.make 方法的返回值。

通常,当你尝试扩展特定绑定时,你会在服务提供者中使用钩子。例如,Database 提供者使用 resolving 钩子注册额外的数据库驱动验证规则。

ts
import { ApplicationService } from '@adonisjs/core/types'

export default class DatabaseProvider {
  constructor(protected app: ApplicationService) {
  }

  async boot() {
    this.app.container.resolving('validator', (validator) => {
      validator.rule('unique', implementation)
      validator.rule('exists', implementation)
    })
  }
}

容器事件

容器在解析绑定或构造类实例后发出 container_binding:resolved 事件。event.binding 属性将是字符串(绑定名称)或类构造函数,event.value 属性是解析后的值。

ts
import emitter from '@adonisjs/core/services/emitter'

emitter.on('container_binding:resolved', (event) => {
  console.log(event.binding)
  console.log(event.value)
})

另请参阅