TypeScript中的依赖注入与控制反转 | 青训营笔记

252 阅读3分钟

这是我参与「第四届青训营 」笔记创作活动的的第17天,总结一下如何使用 TypeScript 实现依赖注入与控制反转

一、前言

1. 为什么需要依赖注入与控制反转

回想一下我们平时如果需要在一个类中依赖另一个类可能会怎么做?

我们首先想到的可能是这样:

class HelloService {
    hello(name: string) {
        console.log(`Hello! This is ${name}.`)
    }
}

class UserService {
    private helloService = new HelloService()

    constructor(private name: string) {}
    
    hello() {
        this.helloService.hello(this.name)
    }
}

const userService = new UserService("VII")
userService.hello() // Hello! This is VII.

但是这种做法有一个明显的缺点:每次创建一个UserService的实例对象都会实例化一次HelloService

因此如果考虑到需要复用已有的HelloService实例对象,我们可能会改成这样:

class UserService {
    constructor(private name: string, private helloService: HelloService) {}
    
    hello() {
        this.helloService.hello(this.name)
    }
}

const helloService = new HelloService()
const userService = new UserService("VII", helloService)
userService.hello() // Hello! This is VII.

这中方法虽然能达到我们的目的,但是如果我们有很多个依赖呢,那么我们需要先实例化所有的依赖类,再通过构造函数传入到调用类中,这无疑是一个繁琐的过程,同时加大了模块之间的耦合度。

那么这个问题我们应该如何优雅地去解决呢?其实SpringBoot或是Nest.js之类的后端框架早就给出了解决方案,也正是运用了依赖注入与控制反转的思想:

SpringBoot中我们可以为Service类(业务逻辑层)加上注解@Service,如果需要在Controller类(接口层)使用,则只需要使用注解@Autowired就可以获取到对应的Service实例

@Service
public class UserService {
    ...
}

@Controller
public class UserController {
    @Autowired
    private UserService userService;
    
    ...
}

Nest.js则通过 TypeScript 中的装饰器语法和reflect-metadata库提供的反射能力实现了差不多的效果(详细示例查看官方文档:Providers | NestJS - A progressive Node.js framework

2. 什么是依赖注入与控制反转

依赖注入和控制反转含义相同,它们是从两个角度描述的同一个概念:

依赖注入指的是将需要的实例赋值给调用者的成员变量(称其为注入),从而实现在调用者内部获取到其依赖的实例的目的;

控制反转则是从调用者的角度出发,调用者依赖的对象不再由调用者去完成实例化(如第一个例子所示),而是交由 IOC 容器完成注入,从而完成了控制权由调用者内部向外部 IOC 容器的反转。

在不使用控制反转时我们进行依赖管理可能是这样的:

class Service1 {
    constructor() { ... }
}

class Service2 {
    constructor(private service1: Service1) { ... }
}

class Service3 {
    constructor(private service2: Service2) { ... }
}

const servcie1 = new Service1()
const service2 = new Service2(service1)
const service3 = new Servcie3(service2)

这种情况下我们最先需要考虑进行实例化的肯定是最底层的Service1,然后自底向上依次完成依赖对象的创建和注入:

image.png

但是如果使用了控制反转,由于依赖的创建和注入由 IOC 容器去完成,我们需要考虑的仅有如何去创建最高层的调用者,IOC 容器将收集调用者中的依赖并自顶向下完成依赖的创建和注入:

@Injectable
class Service1 { ... }

@Injectable
class Service2 {
    @Inject
    private service1?: Service1
}

class Service3 {
    @Inject
    private service2?: Service2
}

const service3 = Factory(Servcie3)

这种模式下我们考虑问题的角度似乎变成了当我们使用Factory方法创建了一个Service3的实例,IOC 容器便通过收集依赖发现其依赖了一个Service2对象,因此便创建了一个Service2实例并注入,接着又发现Service2依赖于Service1...(这只是站在我们视角下的思路,实际运行的顺序肯定不是这样的):

image.png

二、具体实现

顺着上面的思路,我们来使用 TypeScript 实现一个简单的依赖注入与控制反转的框架。

因为需要用到 TypeScript 中的装饰器语法和reflect-metadata库提供的反射机制,不清楚的同学可以查看我另一篇博客,详细介绍了这一部分:

详解 TS 中的装饰器 | 青训营笔记 - 掘金 (juejin.cn)

为了实现全局的状态管理,我们首先需要创建一个 IOC 容器,这里我们定义两个Map,一个实现token(用于唯一标识依赖)到依赖实例的映射,另一个则映射到创建依赖实例的方法(我们称其为Provider):

type Provider<T = any> = (...args: any[]) => T

const providedMap = new Map<string | symbol, any>()
const providerMap = new Map<string | symbol, Provider>()

下面实现通过函数调用完成手动注入依赖

function inject<T = any>(token: string | symbol): T {
    if (!providedMap.has(token)) { // 如果不存在对应的依赖实例,就取出对应的 Provider 创建一个新的实例
        const provider = providerMap.get(token)
        if (provider) {
            providedMap.set(token, provider())
        }
    }
    return providedMap.get(token) // 获取到依赖实例
}

然后我们需要实现通过函数调用完成手动提供依赖

function provide<T = any>(token: string | symbol, provider: Provider<T>, lazy = true): Provider<T> {
    providerMap.set(token, provider)
    lazy || providedMap.set(token, provider()) // 是否在需要使用依赖实例的时候才进行实例化
    return () => inject<T>(token) // 返回对应的 Provider,调用即可获取到依赖实例
}

目前为止我们已经实现了整个框架中的全局依赖管理,我们可以像这样使用:

class HelloService {
    hello(name: string) {
        console.log(`Hello! This is ${name}.`)
    }
}

class UserService {
    constructor(private name: string) {}
    
    hello() {
        const helloService = inject<HelloService>(HelloService.name) // 依赖注入
        helloService.hello(this.name)
    }
}

provide(HelloService.name, () => new HelloService()) // 提供依赖
const userService = new UserService("VII")
userService.hello() // Hello! This is VII.

接下来我们使用装饰器语法和反射机制简化整个流程:

首先因为依赖注入有两种形式,一种是注入到调用类的属性,另一种是注入到调用类构造函数的参数(分别对应 1.1 中两种形式的解决方案),因此我们可以实现一个既可以装饰属性也可以装饰参数的装饰器用于收集调用类中的依赖:

const PROP_DEPS_METADATA = "dependencies:property"
const PARAM_DEPS_METADATA = "dependencies:parameter"

type PropDeps = Map<string, string | symbol>
type ParamDeps = Map<number, string | symbol>

function Inject(token?: string | symbol) {
    return function (target: any, propertyKey: string, paramIndex?: number) {
        if (paramIndex === undefined) { // 属性装饰器
            // 对应注入到属性,将属性的名称映射到对应依赖的 token
            const dependencies: PropDeps
                = Reflect.getMetadata(PROP_DEPS_METADATA, target) ?? new Map()
            dependencies.set(propertyKey,
                token || Reflect.getMetadata("design:type", target, propertyKey))
            Reflect.defineMetadata(PROP_DEPS_METADATA, dependencies, target)
        } else { // 构造函数的参数装饰器
            // 对应注入到构造函数的参数,将构造函数参数的位置映射到对应依赖的 token
            const dependencies: ParamDeps
                = Reflect.getMetadata(PARAM_DEPS_METADATA, target) ?? new Map()
            dependencies.set(paramIndex,
                token || Reflect.getMetadata("design:paramtypes", target)[paramIndex])
            Reflect.defineMetadata(PARAM_DEPS_METADATA, dependencies, target)
        }
    }
}

接下来我们需要实现一个工厂函数,用于完成调用类的实例化和注入依赖:

function Factory<T extends object>(target: ConstructorType<T>, ...args: any[]): T {
    const paramDeps: ParamDeps | undefined = Reflect.getMetadata(PARAM_DEPS_METADATA, target)
    paramDeps?.forEach((token, index) => {
        args[index] = inject(token)
    }) // 注入构造函数参数中的依赖
    const instance = new target(...args)
    const propDeps: PropDeps | undefined
        = Reflect.getMetadata(PROP_DEPS_METADATA, target.prototype)
    propDeps?.forEach((token, prop) => {
        Object.defineProperty(instance, prop, {value: inject(token)})
    }) // 注入属性中的依赖
    return instance
}

最后我们实现一个装饰器用于提供依赖:

function Injectable(token?: string | symbol) {
    return function (target: ConstructorType) {
        provide(() => Factory(target), {token: token || target.name})
    }
}

到现在为止我们就实现了一个简单的具有依赖注入与控制反转特性的框架,具体使用可以看下面这个例子:

@Injectable()
class HelloService {
    hello(name: string) {
        console.log(`Hello! This is ${name}.`)
    }
}

@Injectable()
class DateService {
    now() {
        return new Date()
    }
}

class UserService {
    name: string
    
    @Inject(HelloService.name)
    private helloService?: HelloService

    constructor(name: string, @Inject(DateService.name) private dateService?: DateService) {
        this.name = name
    }

    hello() {
        this.helloService!.hello(this.name)
        console.log(this.dateService!.now())
    }
}

const userService = Factory(UserService, "VII")
userService.hello()

看看运行的结果:

image.png

三、个人总结

今天详细地讲解了什么是依赖注入与控制反转,并且实现了一个具有这样特性的简单的全局依赖管理框架。依赖注入与控制反转能帮助我们实现各个模块之间的解耦,无论是后端 MVC 框架还是前端如 Flutter 中的 GetX 这样的框架,都是这种思想的具体实现,足以体现其重要性。

四、引用与参考

依赖注入和控制反转的理解_这个名字先用着的博客-CSDN博客_依赖注入和控制反转

es7之Reflect Metadata_街边吃垃圾的博客-CSDN博客_reflect-metadata