依赖注入
每个 AdonisJS 应用的核心都是一个 IoC 容器,它能够以近乎零配置的方式构建类并解析依赖。
IoC 容器服务于以下两个主要用例。
- 为第一方和第三方包提供 API,用于在容器中注册和解析绑定(更多内容请见绑定)。
- 自动解析依赖并将其注入到类构造函数或类方法中。
让我们从向类中注入依赖开始。
基础示例
自动依赖注入依赖于 TypeScript 旧版装饰器实现 和 Reflection metadata API。
在以下示例中,我们创建一个 EchoService 类,并将它的一个实例注入到 HomeController 类中。你可以通过复制粘贴代码示例来跟着练习。
第 1 步. 创建 Service 类
首先在 app/services 文件夹中创建 EchoService 类。
export default class EchoService {
respond() {
return 'hello'
}
}
第 2 步. 将服务注入控制器
在 app/controllers 文件夹中创建一个新的 HTTP 控制器。或者,你也可以使用 node ace make:controller home 命令。
在控制器文件中导入 EchoService,并将其作为构造函数依赖接受。
import EchoService from '#services/echo_service'
export default class HomeController {
constructor(protected echo: EchoService) {
}
handle() {
return this.echo.respond()
}
}
第 3 步. 使用 inject 装饰器
为了让自动依赖解析生效,我们必须在 HomeController 类上使用 @inject 装饰器。
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 类的实例。
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 类的实例。
使用容器的好处在于,你可以拥有深度嵌套的依赖,而容器可以为你解析整棵树。唯一的条件就是使用 @inject 装饰器。
使用方法注入
方法注入用于将依赖注入到类方法中。要让方法注入生效,你必须在方法签名之前加上 @inject 装饰器。
让我们延续前面的示例,将 EchoService 依赖从 HomeController 构造函数移到 handle 方法中。
在控制器中使用方法注入时,请记住第一个参数会接收一个固定值(即 HTTP 上下文),其余参数则使用容器解析。
import EchoService from '#services/echo_service'
import { inject } from '@adonisjs/core'
@inject()
export default class HomeController {
constructor(private echo: EchoService) {
}
@inject()
handle(ctx, echo: EchoService) {
return echo.respond()
}
}
仅此而已!这一次,EchoService 类实例会被注入到 handle 方法中。
何时使用依赖注入
建议你在项目中利用依赖注入,因为 DI 会在应用的不同部分之间创建松耦合。这样一来,代码库会变得更容易测试和重构。
不过,你必须要小心,不要把依赖注入的理念推向极端,以至于开始失去它的好处。例如:
- 你不应该将
lodash这样的辅助库作为类的依赖注入。应该直接导入并使用它。 - 对于那些几乎不可能被替换或替换的组件,你的代码库可能并不需要松耦合。例如,你可能更倾向于导入
logger服务,而不是将Logger类作为依赖注入。
直接使用容器
你的 AdonisJS 应用中的大多数类,如控制器、中间件、事件监听器、校验器和邮件发送器,都是使用容器构建的。因此你可以利用 @inject 装饰器进行自动依赖注入。
对于那些你想自行使用容器构建类实例的情况,可以使用 container.make 方法。
container.make 方法接受一个类构造函数,并在解析其所有依赖后返回它的一个实例。
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 方法接受以下参数。
- 类的一个实例。
- 要在类实例上运行的方法名。容器会解析依赖并将它们传递给该方法。
- 一个可选的固定参数数组,用于传递给该方法。
class EchoService {}
class SomeService {
@inject()
run(echo: EchoService) {
}
}
const service = await app.container.make(SomeService)
/**
* 一个 Echo 类的实例会被传入
* run 方法
*/
await app.container.call(service, 'run')
容器绑定
容器绑定是 AdonisJS 中存在 IoC 容器的主要原因之一。绑定充当了你安装的包与你的应用之间的桥梁。
绑定本质上是一个键值对,键是绑定的唯一标识符,值是返回该值的工厂函数。
- 绑定名可以是
string、一个symbol,或一个类构造函数。 - 工厂函数可以是异步的,并且必须返回一个值。
你可以使用 container.bind 方法注册一个容器绑定。以下是一个在容器中注册并解析绑定的简单示例。
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 类的一个单例实例。
容器绑定的概念在 JavaScript 生态中并不常用。因此,请随时加入我们的 Discord 社区 来澄清你的疑问。
在工厂函数内解析绑定
你可以在绑定工厂函数内从容器解析其他绑定。例如,如果 MyFakeCache 类需要来自 config/cache.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 方法注册一个单例绑定。
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 方法直接将值绑定到容器。
this.app.container.bindValue('cache', new MyFakeCache())
别名
你可以使用 alias 方法为绑定定义别名。该方法接受别名作为第一个参数,接受一个对现有绑定或类构造函数的引用作为别名的值。
this.app.container.singleton(MyFakeCache, async () => {
return new MyFakeCache()
})
this.app.container.alias('cache', MyFakeCache)
为绑定定义静态类型
你可以使用 TypeScript 声明合并 为绑定定义静态类型信息。
类型以键值对的形式定义在 ContainerBindings 接口上。
declare module '@adonisjs/core/types' {
interface ContainerBindings {
cache: MyFakeCache
}
}
如果你创建了一个包,可以将上述代码块写在服务提供者文件中。
在你的 AdonisJS 应用中,你可以将上述代码块写在 types/container.ts 文件中。
创建抽象层
容器允许你为应用创建一个抽象层。你可以为一个接口定义一个绑定,并将其解析为一个具体实现。
当你想把六边形架构(也称为端口与适配器原则)应用到你的应用时,此方法很有用。
由于 TypeScript 接口在运行时并不存在,你必须使用抽象类构造函数来充当接口。
export abstract class PaymentService {
abstract charge(amount: number): Promise<void>
abstract refund(amount: number): Promise<void>
}
接下来,你可以创建 PaymentService 接口的一个具体实现。
import { PaymentService } from '#contracts/payment_service'
export class StripePaymentService implements PaymentService {
async charge(amount: number) {
// 使用 Stripe 扣款
}
async refund(amount: number) {
// 使用 Stripe 退款
}
}
现在,你可以在你的 AppProvider 内的容器中注册 PaymentService 接口和 StripePaymentService 具体实现。
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 接口,并在你的应用中使用它。
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 方法。
import UserService from '#services/user_service'
import { inject } from '@adonisjs/core'
export default class UsersController {
@inject()
index(service: UserService) {}
}
假设在测试期间,你不想使用真正的 UserService,因为它会发起外部 HTTP 请求。相反,你想使用一个伪造的实现。
但首先,看看你可能会编写的用于测试 UsersController 的代码。
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 方法接受你想要替换的类构造函数,后跟一个用于返回替代实现的工厂函数。
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 方法恢复原始实现。
app.container.restore(UserService)
// 恢复 UserService 和 PostService
app.container.restoreAll([UserService, PostService])
// 恢复全部
app.container.restoreAll()
上下文相关依赖
上下文相关依赖允许你定义如何为某个给定类解析一个依赖。例如,你有两个服务都依赖 Drive 的 Disk 类。
import { Disk } from '@adonisjs/drive'
export default class UserService {
constructor(protected disk: Disk) {}
}
import { Disk } from '@adonisjs/drive'
export default class PostService {
constructor(protected disk: Disk) {}
}
你希望 UserService 收到一个使用 GCS 驱动的磁盘实例,而 PostService 收到一个使用 S3 驱动的磁盘实例。你可以使用上下文相关依赖来实现。
以下代码必须写在一个服务提供者的 register 方法内。
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 钩子来注册额外的数据库驱动的校验规则。
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 属性则是被解析的值。
import emitter from '@adonisjs/core/services/emitter'
emitter.on('container_binding:resolved', (event) => {
console.log(event.binding)
console.log(event.value)
})
另见
- 容器 README 文件 以与框架无关的方式涵盖了容器的 API。
- 你为什么需要一个 IoC 容器? 在这篇文章中,框架的创建者分享了他使用 IoC 容器的原因。