InversifyJS:容器、作用域与条件绑定

1 分钟阅读
·

使用 InversifyJS 时,理解依赖容器、对象作用域和目标条件绑定的基本机制。

InversifyJS 是面向 TypeScript 和 JavaScript 的依赖注入容器。它将“创建对象”和“使用对象”分开:业务类声明自己依赖什么,容器负责按绑定规则创建并组装对象。

本文使用 InversifyJS 传统的装饰器 API。不同大版本的安装和初始化细节可能不同,但容器、绑定、作用域和条件匹配的概念相同。

最小示例

先定义一个服务标识。接口只存在于 TypeScript 编译期,运行时不能作为容器的 key,因此通常使用 Symbol 或字符串:

import "reflect-metadata";
import { Container, inject, injectable } from "inversify";

const TYPES = {
  Logger: Symbol.for("Logger"),
};

interface Logger {
  info(message: string): void;
}

@injectable()
class ConsoleLogger implements Logger {
  public info(message: string) {
    console.log(message);
  }
}

@injectable()
class UserService {
  public constructor(@inject(TYPES.Logger) private readonly logger: Logger) {}

  public createUser(name: string) {
    this.logger.info(`create user: ${name}`);
  }
}

const container = new Container();
container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
container.bind<UserService>(UserService).toSelf();

container.get(UserService).createUser("Alice");

这个例子包含三个角色:

  • @injectable() 让类可以被容器构造;
  • @inject(TYPES.Logger) 声明构造函数参数需要哪个服务;
  • bind(...).to(...) 注册服务标识和具体实现的对应关系。

调用 container.get(UserService) 时,容器先找到 UserService 的绑定,读取构造函数参数的注入信息,再按 TYPES.Logger 找到 ConsoleLogger 的绑定,最后完成实例化。

Container 的职责

Container 保存绑定规则,并从某个服务标识开始解析完整依赖图。应用通常只在启动阶段创建和配置容器;业务代码通过构造函数接收依赖,不需要在每个类中保存或传递 Container

const container = new Container();

container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
container.bind<UserService>(UserService).toSelf();

const service = container.get(UserService);

常见的绑定方式如下:

// 绑定到可由容器构造的类
container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);

// 类本身作为服务标识
container.bind<UserService>(UserService).toSelf();

// 绑定已有对象
container.bind<Logger>(TYPES.Logger).toConstantValue(consoleLogger);

// 每次解析时运行工厂函数
container.bind<Date>(TYPES.Now).toDynamicValue(() => new Date());

toConstantValue 适合配置、已经初始化的 SDK 客户端等明确共享的对象。toDynamicValue 适合依赖运行时上下文的值;工厂函数的执行次数仍受后续作用域设置影响。

容器解析失败通常由以下原因引起:服务没有绑定、被注入的类没有 @injectable()、运行环境没有在入口加载 reflect-metadata,或者同一个标识有多个绑定但调用了 get。后者应改用 getAll,或补充条件绑定以消除歧义。

三种作用域

作用域决定容器在何时复用实例。InversifyJS 的常用作用域是 TransientSingletonRequest

Transient:每个注入点创建新实例

Transient 是类绑定的默认作用域。每次容器需要这个服务时都会创建一个新对象,即使这些请求属于同一次 container.get 调用。

container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inTransientScope();

例如 UserServiceAuditService 都依赖 Logger,并且它们在一次对象图解析中都需要该服务,Transient 绑定会创建两个 ConsoleLogger 实例。没有状态、创建成本低的服务适合这一作用域。

Singleton:同一容器复用一个实例

Singleton 会在容器第一次解析该绑定时创建实例,之后该容器中对相同绑定的解析都会返回这个实例。

container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inSingletonScope();

单例的边界是 Container,不是整个进程。两个不同容器各自会有自己的 ConsoleLogger。如果单例对象持有可变请求状态,后续请求可能读到前一次请求的数据;这类状态应放到请求上下文或显式参数中。

Request:一次解析树内复用实例

Request 的“请求”指一次根解析操作,例如一次 container.get(UserService),并不等同于 HTTP 请求。容器在解析这棵依赖树时,会复用同一绑定第一次创建的实例;下一次 container.get 会重新创建。

container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inRequestScope();

下面的例子中,leftright 是同一个根对象解析过程中得到的两个 TraceContext,所以它们指向同一个实例:

@injectable()
class TraceContext {}

@injectable()
class UsesTrace {
  public constructor(@inject(TYPES.Trace) public readonly trace: TraceContext) {}
}

@injectable()
class Handler {
  public constructor(
    @inject(UsesTrace) public readonly left: UsesTrace,
    @inject(UsesTrace) public readonly right: UsesTrace,
  ) {}
}

container.bind<TraceContext>(TYPES.Trace).to(TraceContext).inRequestScope();
container.bind<UsesTrace>(UsesTrace).toSelf();
container.bind<Handler>(Handler).toSelf();

const first = container.get(Handler);
const second = container.get(Handler);

first.left.trace === first.right.trace; // true
first.left.trace === second.left.trace; // false

Request 作用域适合在一次解析内共享的临时对象。它不能自动跟踪异步调用链,也不能自动映射到 Web 框架的一次 HTTP 请求;需要按 HTTP 请求共享数据时,应在应用层建立明确的请求容器或上下文管理方案。

用名称和标签选择实现

同一个服务标识可以绑定多个实现。此时容器需要额外条件来判断在某个注入点应该使用哪一个实现。

const TYPES = {
  Weapon: Symbol.for("Weapon"),
};

interface Weapon {
  hit(): string;
}

@injectable()
class Katana implements Weapon {
  public hit() {
    return "cut";
  }
}

@injectable()
class Shuriken implements Weapon {
  public hit() {
    return "throw";
  }
}

@namedwhenTargetNamed

名称条件是一个字符串。注入点通过 @named 声明名称,绑定通过 whenTargetNamed 匹配它:

import { inject, named } from "inversify";

container.bind<Weapon>(TYPES.Weapon).to(Katana).whenTargetNamed("melee");
container.bind<Weapon>(TYPES.Weapon).to(Shuriken).whenTargetNamed("ranged");

@injectable()
class Ninja {
  public constructor(
    @inject(TYPES.Weapon) @named("melee") public readonly melee: Weapon,
    @inject(TYPES.Weapon) @named("ranged") public readonly ranged: Weapon,
  ) {}
}

文档中的 targetName 指的是依赖目标的参数名或属性名;它可以参与匹配,但不能像 @named 一样在注入点显式传入业务值。参数名在压缩、重构后可能变化,优先用 @named 表示稳定的业务分类。

@taggedwhenTargetTagged

标签由键和值组成,适合一个服务需要多个筛选维度的场景。例如按环境选择配置:

import { inject, tagged } from "inversify";

const TAGS = {
  Environment: "environment",
};

interface ApiEndpoint {
  readonly url: string;
}

container
  .bind<ApiEndpoint>(TYPES.ApiEndpoint)
  .toConstantValue({ url: "https://api.example.com" })
  .whenTargetTagged(TAGS.Environment, "production");

container
  .bind<ApiEndpoint>(TYPES.ApiEndpoint)
  .toConstantValue({ url: "https://api-staging.example.com" })
  .whenTargetTagged(TAGS.Environment, "staging");

@injectable()
class ApiClient {
  public constructor(
    @inject(TYPES.ApiEndpoint)
    @tagged(TAGS.Environment, "production")
    public readonly endpoint: ApiEndpoint,
  ) {}
}

名称可以看作固定键下的单个字符串值;标签提供键和值两个维度。只有“近战、远程”这类单一分类时,@named 更直接;需要同时表达环境、区域、协议等维度时,使用 @tagged 更合适。

使用时的边界

依赖注入解决的是对象创建与依赖装配问题,不会自动改善模块设计。服务标识过多、条件绑定层层叠加时,依赖关系会变得难以追踪。更稳妥的做法是让绑定模块按业务边界组织,为大多数依赖保留一个默认实现,只在确实需要替换实现的位置使用名称或标签。

测试时可以为测试容器注册替代实现,或覆盖特定绑定,让业务类继续通过构造函数接收依赖。这样测试无需修改业务类,也能控制外部服务、时间和随机数等不稳定输入。

参考


177 字 · 40 段落
ximing

Written by ximingFollow onGitHub

相关文章