chunpu / blog

personal blog render by jekyll
MIT License
51 stars 8 forks source link

解耦系列:一篇文章说清楚依赖注入,控制反转,依赖倒置 #107

Open chunpu opened 1 year ago

chunpu commented 1 year ago

名词解释

  1. 依赖注入 = Dependency Injection = DI 一种设计模式
  2. 依赖倒置 = Dependency Inversion Principle = DIP 一种设计原则
  3. 控制反转 = Inversion of Control = IOC 一种设计模式

这几个原则和设计模式核心目的都是为了解耦,降低复杂度。

之所以把这三个概念放在一块讲是因为生活中很多例子都用到了里面的思想。

生活场景类比

传统模式下我们出行需求是自己买一个车,自己去店里买车,买车位,买车险,车坏了就自己修,不想要的时候还要自己卖车,虽然控制权完全在自己手中,但需要操心的事很多。

强控制下意味着强耦合,麻烦事也多,那如何解耦呢?

解法就是放弃控制权,变成松耦合。

我们选择在需要用车的时候打车租车,这样我们不用买车,租车位,买保险,管这些杂事。

看一下我们的解耦成果,我们依赖的是滴滴这样的打车租车软件,而不是一辆具体的车。这样我们不再关心车的各种杂事,也非常容易换车。

如何理解依赖注入(DI)

依赖注入是一种设计模式,也是控制反转的一种具体实现。

注入的字面意思是把外部的东西放到内部,与之相反的就是内部的东西由内部自然产生。 依赖注入的具体实现是传参,最常见的就是构造函数的参数。

const dataService = new DataService()
const myModule = new MyModule(dataService)

MyModule 没有用自己去创建 dataService,而是通过构造函数的参数直接拿了外部的 dataService。 dataService 被注入到了 myModule 中。

依赖注入解耦了什么?

传统模式:高层模块使用低层模块,高层模块必须 import 低层模块。 依赖注入模式:高层通过接口参数的方法使用低层模块的实例,不再有直接的 import 关系。 注意,即便是使用实例,我们也要知道这个实例的类型,因此高层还是需要依赖一个接口, 而此处用到了依赖倒置。

依赖倒置

依赖倒置更合适的名字应该叫面向接口编程

注意,依赖倒置是一种面向对象的设计原则,而非设计模式。

依赖倒置原则的核心思想是:

  1. 高层模块不应该依赖于低层模块,两者都应该依赖于抽象接口。
  2. 抽象接口不应该依赖于具体实现,具体实现应该依赖于抽象接口。

依赖倒置原则下多了一个接口中间层:

  1. 高层模块
  2. 接口中间层
  3. 低层模块

为什么叫倒置呢?因为传统编程是高层模块依赖低层模块,而依赖倒置则认为低层模块要依赖接口中间层定义。

比如一个文件管理 App 要依赖文件 fs 模块。

一开始我们只需要 windows 平台,所以支持了 windows 的 fs,现在客户希望 mac 也支持,就无法轻易实现,因为我们无法轻易替换低层模块。但如果客户端程序设计之初就是依赖一个 fs 接口,而各端的 fs 模块又实现了这个 fs 接口,那 fs 模块就可以随意替换了。

生活中的例子就是 HDMI 接口,如果电脑和显示器在设计的时候是直接连接的,那电脑和显示器就无法分离,完全耦合。但电脑和显示器是分体式设计,通过 HDMI 接口来连接,这样显示器可以任意换主机,主机也可以任意换显示器。

我们发现,不仅是高层低层模块之间的耦合,其实任何大型模块的耦合,如果未来有替换的可能,都应该通过接口来耦合,这样我们的模块就解耦了,可以单独用于测试,将来重构也很容易。

在实际开发中,我们应该先定义接口,再进行模块开发。高层模块依赖并调用接口,低层模块依赖并实现接口。

// 定义一个接口,表示依赖的功能
interface ILogger {
  log(message: string): void
}

// 实现一个具体的依赖
class ConsoleLogger implements ILogger {
  log(message: string): void {
    console.log(message)
  }
}

// 使用依赖的类
class UserService {
  private logger: ILogger

  // 通过构造函数进行依赖注入
  constructor(logger: ILogger) {
    this.logger = logger
  }

  addUser(name: string): void {
    this.logger.log(`Adding user: ${name}`)
    // 其他逻辑...
  }
}

// 创建依赖实例
const logger = new ConsoleLogger()

// 创建使用依赖的实例,并通过依赖注入传入依赖实例
const userService = new UserService(logger)

// 使用依赖的功能
userService.addUser('John Doe')

控制反转

传统模式下对象自己控制自己,反转成 IOC 容器来控制对象。

IOC 容器(IOC Container,Dependency Injection Container)

IOC 容器非常有误导性,容器的特点是独立,隔离等,所以 docker 容器非常好理解。 而 IOC 容器更好的名字应该叫:实例依赖管理器。

传统模块下,模块控制自己的依赖,比如依赖的实例化等。 控制反转设计模式下,由实例依赖管理器(IOC 容器)控制各个模块。

  1. 对象的实例化:IOC 容器负责创建对象的实例,根据配置信息或注解来确定对象的类型和依赖关系。
  2. 很关键,并不是对象 A 里面创建对象 B 的实例,因为实例化都在容器中,完全不存在对象 A 和对象 B 互相依赖。
  3. 依赖注入:IOC 容器通过依赖注入的方式将对象的依赖关系注入到对象中。这意味着对象不再负责自己的依赖关系的管理,而是由容器来管理。
  4. 生命周期管理:IOC 容器可以管理对象的生命周期,包括对象的创建、初始化、销毁等。它可以确保对象在正确的时间创建和销毁,以及在需要时执行初始化操作。
  5. 配置管理:IOC 容器通常使用配置信息来描述对象之间的依赖关系。
interface IocContainer {
  // 注册一个功能类,dependency 一般是一个类
  register<T>(name: string, dependency: T): void
  // resolve = 获得单例,初始化,resolve 可以是 async
  resolve<T>(name: string): T
}

一个典型的 IOC 容器例子如下

class LazyInitializedObject {
  constructor(param) {
    this.param = param
    console.log(`LazyInitializedObject is being created with param: ${param}`)
  }

  async initialize() {
    // 模拟异步请求
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log(
          `LazyInitializedObject is initialized with param: ${this.param}`
        )
        resolve()
      }, 2000)
    })
  }

  doSomething() {
    console.log('LazyInitializedObject is doing something.')
  }
}

class Container {
  constructor() {
    this.dependencies = {}
  }

  register(name, dependency) {
    this.dependencies[name] = dependency
  }

  async resolve(name, ...args) {
    const dependency = this.dependencies[name]
    if (dependency) {
      if (typeof dependency === 'function') {
        // 如果依赖是一个构造函数,则延迟初始化并存储实例
        const instance = new dependency(...args)
        await instance.initialize() // 异步初始化
        this.dependencies[name] = instance // 缓存实例
        return instance
      } else {
        // 如果依赖已经是一个实例,则直接返回
        return dependency
      }
    }
    throw new Error(`Dependency not found: ${name}`)
  }
}

IOC 容器是可以嵌套的,就像一个组件树一样,IOC 容器 的 dependencies 可以是一个 IOC 容器,然后它又有自己的 dependencies。

我写了一个非常简单的 ioc 容器 https://github.com/chunpu/minioc/blob/master/example/example.ts, 越简单越容易理解哦~

总结

依赖倒置,依赖注入,控制反转都是为了解耦。

  1. 依赖 interface 而不是依赖具体模块,接口意味着标准化,标准化很重要。
  2. 放弃控制权,不要当一个控制狂。
  3. 统一在容器实例中初始化模块,容器引入大量具体类,因为需要 new Module,这也是唯一的具体实现耦合。
  4. 直接把实例注入模块,避免模块引用其他具体模块。