Published on

依赖注入:打造高内聚,低耦合的代码艺术

Authors
  • avatar
    Name
    青雲
    Twitter

引言

想象一下,你正在举行一个派对,准备为大家提供一顿饭。你可以亲自下厨,也可以请一位厨师来做这些事情。如果你决定聘请一位厨师,那么你需要为他提供一切必需的厨房用品:锅碗瓢盆、调料、食材等。你不需要告诉厨师去哪里找油或盐,因为这些都已经被“注入”到他的工作环境中,厨师只需关心如何用这些工具和材料来做出美味的菜肴。 类似地,软件开发中的“依赖注入”概念也正是以这样的思路为基础。在这个比喻中,厨师代表一个系统或模块,而厨房用品就是它所依赖的服务或对象。依赖注入(Dependency Injection, DI) 就是一种技术,允许我们的模块或系统在创建时,自动接收它所需的依赖。

基本概念

控制反转 (IoC)

控制反转(Inversion of Control,简称IoC)是一种软件架构设计原理,它将组件之间的依赖关系的控制权,从程序代码内部转移给外部容器。传统的程序设计中,组件的创建和其他组件的选择都是由程序本身控制的,但是在控制反转的设计中,这些决定转移到了程序之外。

传统方式(没有 IoC)

在不使用IoC的情况下,组件之间的依赖关系通常是静态的,或者说是硬编码的,这意味着组件会直接实例化它们所依赖的其它组件。 以下是一个简单的用户服务类的例子,它需要一个日志记录器来记录消息,实现了没有使用IoC的传统方法:

// Logger.js
class Logger {
  log(message) {
    console.log(message);
  }
}

// UserService.js
class UserService {
  constructor() {
    this.logger = new Logger();  // 直接依赖实例化
  }

  createUser(name) {
    // ... 创建用户的逻辑 ...
    this.logger.log(`User ${name} created!`);  // 使用Logger类的实例
  }
}

// 使用UserService的情况
const userService = new UserService();
userService.createUser('Alice');

在这个例子中,UserService 类直接创建了一个 Logger 实例。这导致了 UserService 类与 Logger 类之间产生了紧密的耦合关系。这种设计使得替换或修改日志记录行为(比如在测试中使用模拟或假的日志记录器)变得非常困难。

使用IoC

IoC容器的引入允许程序的各个部分通过外部机制配置它们的依赖,而不是内部静态地维护这些关系。下面我们通过使用IoC容器来重构上面的例子:

// Logger.js remains unchanged

// UserService.js with IoC
class UserService {
  constructor(logger) { // 依赖通过构造函数传入
    this.logger = logger;
  }

  createUser(name) {
    // ... 创建用户的逻辑 ...
    this.logger.log(`User ${name} created!`);
  }
}

// IoC Container
class Container {
  constructor() {
    this.components = {};
  }

  register(name, dependencies, implementation) {
    if (dependencies.length) {
      dependencies = dependencies.map(
        (dependency) => this.components[dependency]
      );
    }
    this.components[name] = new implementation(...dependencies);
  }

  get(name) {
    return this.components[name];
  }
}

// Main.js - application setup
const container = new Container();
container.register('logger', [], Logger);
container.register('userService', ['logger'], UserService);

const userService = container.get('userService');
userService.createUser('Alice');

在IoC方式中,程序不再直接控制用户服务如何创建日志记录器。相反,一个IoC容器负责实例化 Logger ,并将它注入到 UserService 构造函数中。这样,如果需要改变日志行为,只需要调整容器配置即可,不需修改 UserService 类的代码。 当我们重新配置容器以使用不同的 Logger 实现时,UserService 的行为也会相应改变,而无需任何进一步的代码修改。这就是IoC提供的强大灵活性和可扩展性。

依赖注入的方法

依赖注入(Dependency Injection)是实现控制反转(IoC)原则的一种方法。通过这种方式,一个对象的依赖不再由对象本身在内部创建,而是由外部传入,通常通过构造器、setter方法、或者类的属性实现。

构造器注入(Constructor Injection)

构造器注入是最常用的依赖注入方法之一。依赖项通过类的构造器参数传入,确保了所需依赖的不可变性和实例化后即刻的可用性。

interface IDependency {
  doWork(): void;
}

class ConcreteDependency implements IDependency {
  doWork() {
    console.log("Working...");
  }
}

class Consumer {
  private dependency: IDependency;

  constructor(dependency: IDependency) {
    this.dependency = dependency;
  }

  execute() {
    this.dependency.doWork();
  }
}

// 使用时
const dependency = new ConcreteDependency();
const consumer = new Consumer(dependency);
consumer.execute();

Setter方法注入(Setter Injection)

Setter注入允许依赖关系在对象创建后的任何时间点被设置。一般用于可选依赖,或者在初始化构造后需要变化依赖的情况。

class ConsumerWithSetter {
  private dependency: IDependency;

  setDependency(dependency: IDependency): void {
    this.dependency = dependency;
  }

  execute() {
    if (this.dependency) {
      this.dependency.doWork();
    }
  }
}

// 使用时
const consumerWithSetter = new ConsumerWithSetter();
const dependency = new ConcreteDependency();
consumerWithSetter.setDependency(dependency);
consumerWithSetter.execute();

接口注入(Interface Injection)

接口注入定义了一个接口来指定注入点,并通过实现该接口的方式为类提供依赖。

interface IDependencyInjector {
  injectDependency(dependency: IDependency): void;
}

class ConsumerWithInterfaceInjection implements IDependencyInjector {
  private dependency: IDependency;

  injectDependency(dependency: IDependency): void {
    this.dependency = dependency;
  }

  execute() {
    this.dependency.doWork();
  }
}

// 使用时
const consumerWithInterfaceInjection = new ConsumerWithInterfaceInjection();
const dependency = new ConcreteDependency();
consumerWithInterfaceInjection.injectDependency(dependency);
consumerWithInterfaceInjection.execute();

注解/装饰器注入(Annotation/Decorator Injection)

注解或装饰器注入常用于支持元数据的编程语言中,比如Java、TypeScript等。注解或装饰器被添加到类属性、构造器参数或方法参数上,来自动注入依赖项。

function Injectable() {
  // 这是一个装饰器工厂
  return function(target: any) {
    // 这里可以插入依赖注入的逻辑
    // ...
  };
}

@Injectable()
  class ConsumerWithDecorator {
    private dependency: IDependency;

    // @Inject 装饰器用于标记需要注入的依赖项,IoC容器识别这些标记并进行适当的依赖注入。
    constructor(@Inject('IDependency') dependency: IDependency) {
      this.dependency = dependency;
    }

    execute() {
      this.dependency.doWork();
    }
  }

// IoC容器会根据装饰器的标记来注入适当的依赖
const container = // ... 初始化IoC容器
const consumerWithDecorator = container.resolve<ConsumerWithDecorator>(ConsumerWithDecorator);
consumerWithDecorator.execute();

依赖注入可以以不同的方式进行,每种方法都有其适用的场景。

  • 构造器注入保证了依赖的不可变性和立即可用性。
  • Setter注入提供了更大的灵活性。
  • 接口注入允许通过实现特定接口来定义注入点。
  • 注解/装饰器注入在现代框架中广泛使用,提供了编写干净和自描述代码的能力。

依赖倒置原则 (DIP)

依赖倒置原则(Dependency Inversion Principle)是面向对象设计五大原则之一,通常用于减少代码间的耦合,提高系统的灵活性和可维护性。基本思想是:

  • 高层模块不应依赖于低层模块,两者都应该依赖于抽象。
  • 抽象不应依赖于细节,细节应该依赖于抽象。

换言之,这个原则建议我们在设计代码时使得高层模块和低层模块之间通过抽象相互通信,而不是通过具体实现相互通信。这保证了当低层模块的具体实现变化时,不会影响到高层模块的业务逻辑。

不遵守 DIP

// 低层模块的具体实现
class ConcreteRepository {
  fetchData() {
    return "Data from ConcreteRepository";
  }
}

// 高层模块
class DataService {
  // 直接依赖于低层模块的具体实现
  private repository = new ConcreteRepository();

  getData() {
    return this.repository.fetchData();
  }
}

在上述代码中,DataService(高层模块)直接依赖于ConcreteRepository(低层模块的具体实现)。这违背了依赖倒置原则,因为改变ConcreteRepository的实现会影响到DataService

遵守 DIP

interface IRepository {
  fetchData(): string;
}

// 低层模块的实现依赖于抽象
class BetterConcreteRepository implements IRepository {
  fetchData() {
    return "Data from BetterConcreteRepository";
  }
}

// 高层模块同样依赖于抽象
class BetterDataService {
  private repository: IRepository;

  constructor(repository: IRepository) {
    this.repository = repository;
  }

  getData() {
    return this.repository.fetchData();
  }
}

// 使用时
const repository = new BetterConcreteRepository();
const service = new BetterDataService(repository);

在遵守了DIP的版本中,BetterDataService依赖于IRepository这一抽象,而不是一个具体的类。我们可以替换掉BetterConcreteRepository而不影响BetterDataService的业务逻辑,因为所有依赖都是面向接口编程的。

循环依赖

循环依赖发生在两个或多个模块互相引用对方,形成一个闭环。在大多数情况下,循环依赖会导致维护困难,并可能引起初始化和运行时错误。举个简单的例子,模块A依赖模块B,而模块B同时依赖模块A。

// A.ts
import { B } from './B';

export class A {
  bInstance: B;

  constructor() {
    this.bInstance = new B();
  }
}

// B.ts
import { A } from './A';

export class B {
  aInstance: A;

  constructor() {
    this.aInstance = new A();
  }
}

重新设计接口

我们可以创建一个接口,让A依赖于这个接口而不是依赖于B的具体实现。

// IInterface.ts
export interface IInterface {
  someMethod(): void;
}

// A.ts
import { IInterface } from './IInterface';

export class A {
  bInstance: IInterface;

  constructor(bInstance: IInterface) {
    this.bInstance = bInstance;
  }
}

// B.ts
import { A } from './A';
import { IInterface } from './IInterface';

export class B implements IInterface {
  someMethod() {
    // ...
  }
}

// Usage
const bInstance = new B();
const aInstance = new A(bInstance);

使用中介者模式

引入一个中介者来消除直接依赖。

// Mediator.ts
import { A } from './A';
import { B } from './B';

export class Mediator {
  aInstance: A;
  bInstance: B;

  constructor() {
    this.aInstance = new A(this);
    this.bInstance = new B(this);
  }
}

// A.ts
import { Mediator } from './Mediator';

export class A {
  mediator: Mediator;

  constructor(mediator: Mediator) {
    this.mediator = mediator;
  }
}

// B.ts
import { Mediator } from './Mediator';

export class B {
  mediator: Mediator;

  constructor(mediator: Mediator) {
    this.mediator = mediator;
  }
}

// Usage
const mediator = new Mediator();

使用依赖注入解决循环依赖

依赖注入(DI)框架可以管理类的创建和依赖项的注入过程,允许更灵活地处理依赖项,如延迟加载或使用代理来解决循环依赖问题。

前端 DI 框架

InversifyJS

InversifyJS是一个强大的和轻量级的依赖注入框架,专门为TypeScript和JavaScript项目设计,能够提供类似于后端语言(如Java和C#)中使用的IoC容器的功能。

  • 特点:
    • 专为TypeScript设计,充分利用其类型系统。
    • 通过注解和装饰器语法提供了声明式依赖注入。
    • 可通过绑定和作用域实现复杂的DI配置。
import { Container, injectable, inject } from 'inversify';

@injectable()
  class SomeService {
    // Some implementation
  }

@injectable()
  class SomeController {
    constructor(@inject(SomeService) private someService: SomeService) {}
  }

const container = new Container();
container.bind(SomeService).toSelf();
container.bind(SomeController).toSelf();

const someController = container.get(SomeController);

Tsyringe

Tsyringe是一个轻量级、反射的依赖注入容器,适用于TypeScript。它借助TypeScript的装饰器语法来实现依赖的注册和解析。

  • 特点:
    • 易于使用,配置简单。
    • 通过构造函数注入依赖。
    • 支持属性注入和容器解析。
import { container, injectable, inject } from 'tsyringe';

@injectable()
  class SomeService {
    // Some implementation
  }

@injectable()
  class SomeController {
    constructor(private someService: SomeService) {}
  }

container.registerSingleton(SomeService);
const someController = container.resolve(SomeController);

Awilix

Awilix是一个用JavaScript编写的灵活的依赖注入容器,支持类和函数注入模式。

  • 特点:
    • 在JavaScript环境中使用广泛,不限于TypeScript。
    • 提供了易于理解的API,简化了依赖管理。
    • 支持构造函数、函数和类的注入。
const awilix = require('awilix');

class SomeService {}

class SomeController {
  constructor({ someService }) {
    this.someService = someService;
  }
}

const container = awilix.createContainer({
  injectionMode: awilix.InjectionMode.PROXY
});

container.register({
  someService: awilix.asClass(SomeService).singleton(),
  someController: awilix.asClass(SomeController)
});

const someController = container.resolve('someController');

Angular DI

Angular自带的依赖注入系统是内建在Angular框架中的,深度集成,设计用于与Angular组件和服务协同工作。

  • 特点:
    • 内置于Angular框架中,与Angular应用程序的其它部分紧密集成。
    • 通过提供者(Provider)和注解(如@Injectable)进行配置。
    • 支持分层依赖注入系统,可以控制服务的作用域。
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
  class SomeService {}

import { Component } from '@angular/core';

@Component({
  selector: 'app-some-component',
  template: '...'
})
  class SomeComponent {
    constructor(private someService: SomeService) {}
  }

对比

InversifyjsTsyringeAwilixAngular DI
语言支持TSTSTS/JSTS
装饰器支持支持支持可选支持
Runtime开销中等低~中等低~中等
装饰器性/元数据反射需要需要非必需需要
配置复杂度中等与框架绑定
生命周期钩子支持支持支持支持
异步模块依赖有限(通过工厂模式支持)有限(通过工厂模式支持)支持有限
多容器分层支持(父子容器依赖)有限(无继承)有限(作用域)支持(分层注入器)
容器化销毁支持(生命周期管理和自定义清理逻辑)unknown支持框架内部管理
社区和文档较强较强非常强

实现简单的依赖注入工具

依赖分析

使用TypeScript实现一个简单的依赖注入工具,我们首先需要分析类的依赖。TypeScript 的装饰器和反射元数据 API 提供了这样的机制来实现这一功能。 我们先在tsconfig.json中启用一下配置

{
  "compilerOptions": {
    // 确保已启用装饰器
    "experimentalDecorators": true,
    // 开启元数据反射功能
    "emitDecoratorMetadata": true
  }
}

接着创建一个Injectable装饰器,它会在类上添加反射信息。

import 'reflect-metadata';

const Injectable = (): ClassDecorator => target => {
  Reflect.defineMetadata('injectable', true, target);
};

定义容器

接下来需要定义一个IoC容器,它将负责解决和提供依赖。

class Container {
  private providers = new Map();

  resolve<T>(constructor: { new (...args: any[]): T }): T {
    const params = Reflect.getMetadata('design:paramtypes', constructor) || [];
    const injections = params.map((param: new (...args: any[]) => any) => this.resolve(param));
    const instance = new constructor(...injections);
    return instance;
  }
}

创建实例

要通过容器创建实例,需要在容器中注册依赖,然后调用resolve方法。

@Injectable()
  class Dependency {}

@Injectable()
  class Target {
    constructor(public dependency: Dependency) {}
  }

const container = new Container();
const targetInstance = container.resolve(Target);

依赖抽象

TokenProvider可以帮助我们进一步抽象依赖关系,而不是直接依赖具体的类。

type ProviderToken = string | symbol | new (...args: any[]) => any;

class Container {
  private providers = new Map<ProviderToken, any>();

  register(token: ProviderToken, provider: new (...args: any[]) => any) {
    this.providers.set(token, provider);
  }

  resolve<T>(token: ProviderToken): T {
    const target = this.providers.get(token);
    if (!target) throw Error(`No provider found for ${token.toString()}`);
    const params = Reflect.getMetadata('design:paramtypes', target) || [];
    const injections = params.map((param: new (...args: any[]) => any) => this.resolve(param));
    const instance = new target(...injections);
    return instance;
  }
}

惰性依赖 & 循环依赖

惰性依赖是指当依赖被实际需要时,才创建该依赖的实例。要实现惰性依赖,我们通常使用代理或是工厂函数来封装对依赖的真实请求。

type Constructor<T = any> = new (...args: any[]) => T;

interface ILazy<T = any> {
  (): T;
}

function lazy<T>(token: Constructor<T>): ILazy<T> {
  return function lazyInjected() {
    return container.resolve(token);
  };
}

循环依赖(Circular Dependency)指的是两个或多个组件相互依赖彼此,如果不妥善处理,当尝试解析其中一个组件时,将会造成无限递归。解决方法通常是引入一个中间层,允许注册中完成,而不是在解析时。

class Container {
  private instances = new Map<Constructor, any>();
  private constructors = new Map<Constructor, Constructor[]>();

  register<T>(token: Constructor<T>, deps: Constructor[] = []): void {
    this.constructors.set(token, deps);
  }

  resolve<T>(token: Constructor<T>): T {
    if (this.instances.has(token)) {
      return this.instances.get(token);
    }

    if (!this.constructors.has(token)) {
      throw new Error(`No token ${token.name} registration found`);
    }

    // Resolve all dependencies first
    const resolvedDeps = this.constructors.get(token).map(dep => {
      if (dep === token) {
        // Handle a circular dependency by providing a proxy to the instance
        const lazyProxy = new Proxy({ instance: null }, {
          get: (target, prop) => {
            if (target.instance !== null) {
              return target.instance[prop];
            }
            // Lazily resolve the actual dependency
            target.instance = this.resolve(dep);
            return target.instance[prop];
          }
        });

        return lazyProxy;
      }
      return this.resolve(dep);
    });

    // Instantiate and cache
    const instance = new token(...resolvedDeps);
    this.instances.set(token, instance);
    return instance;
  }
}

在上面的实现中,我们有一个Container类,其中包含register方法用于注册类及其依赖,以及resolve方法用于解析类的实例。如果存在循环依赖,则使用Proxy来延迟这个问题,创建代理,而不是立即实例化对象。当代理对象被实际使用时,才会触发依赖项的实例化。

class A {
  constructor(public b?: B) {}
}

class B {
  constructor(public a?: ILazy<A>) {} // Use ILazy to hold a reference to A
}

const container = new Container();
container.register(A, [B]);
container.register(B, [lazy(A)]);  // Use lazy factory for A

const a = container.resolve(A); // 这样做现在不会抛出堆栈溢出错误
const b = container.resolve(B);

console.log(b.a()); // 访问B中A的实例时没有问题

在这个例子中,类A依赖于类B,同时类B也依赖于类A。通过在类B中使用ILazy<A>作为依赖项并且通过延迟工厂lazy进行注入,我们可以延迟对类A的解析请求,直到它被实际需要,这解决了循环依赖问题。

类图

在这个架构中:

  • Container 是核心类,拥有 registerresolve 方法来注册和解析依赖。
  • ILazy 是一个接口,提供了用于惰性依赖的工厂函数。
  • Proxy 用于处理循环依赖,它在必要时延迟实例化。(JavaScript 的 Proxy 对象)
  • Constructor 表示可以在 Container 中注册和解析的通用类型构造函数。(new 关键字来创建新实例的构造函数)

总结

在本文中,我们探讨了依赖注入(DI)的概念、实现方式及其在管理和解耦应用程序组件中的关键作用。我们首先定义了DI和容器的基本原理,然后通过TypeScript代码示例实现了一个简单的DI容器,其具有解决循环依赖和惰性加载特性。

DI使得组件之间的关系更加灵活和模块化,极大地降低了耦合度,并提升了代码的可测试性和可维护性。通过在代码中应用DI原则,我们能够打造高内聚、低耦合的系统,这在软件工程中被认为是良好设计的标志。依赖注入不仅仅是一种编程技巧,更是一种编码艺术,它倡导的是清晰的架构和组件之间明确的契约。