【造个方轮子】依赖注入的TypeScript实现

avatar
@https://www.tuya.com/

作者: 涂鸦-Bayek

来涂鸦工作: job.tuya.com/


🤔️:轮子不是圆的吗?为什么是方的。

😣:因为圆能用。

要说依赖注入(DI,Dependency Injection),就不得不提控制反转(IoC,Inversion of Control),它们几乎已经是绑定概念了,那么两者有什么关系和区别呢。

很多文章会把两者混为一谈,但以我的理解,IoC是一种概念性的设计思想,除开Spring、Angular,就如React、Vue这样的框架,我们按照框架的规则写代码,框架拿到我们的代码进行一系列转换然后执行,这也算控制反转。相反的如jQuery、Lodash这样的工具库,我们调用库的方法实现目的,就不是控制反转。 回到DI,DI则是IoC思想的一种具体模式,主要用于降低Class之间的耦合。在Spring、Nest.js、Angular等框架中依赖注入是基本概念。

本文将基于TS实现一个基础的依赖注入过程,主要体现依赖注入的基本原理。

先看如下代码。

import { AuthService } from "./AuthService";

class AuthController {
  constructor() {
    this.authService = new AuthService(); 
  }
  
  login() {
    this.authService.xxx()
  }
}

我们在controller引入service类,并且直接实例化,类之间是强依赖的。

而在Java Spring、Nest.js类似框架里,将大概是下面这样子。

/** AuthService.ts **/
@Service() // 这个类要交给IoC容器
class AuthService {
	xxx() {
    // xxx
	}
}
/** AuthController.ts **/
import { AuthService } from "./AuthService";

class AuthController {
  @AutoWired() // 容器帮忙创建和注入AuthService
  authService: AuthService;
  
  login() {
    this.authService.xxx()
  }
}

这里没有再手动的new对象,而是给属性打上了一个注解,容器就会实例化对应的类再赋值给这个属性。这样做的好处有:

  1. 方便协同,如果两个类是两位同学一起写,只需要先定义好interface,引入interface作为类型。对方没有开发好这边也不会报错,Class的依赖也彻底没有了。(但是TS无法实现基于interface的自动注入,下面会实现基于abstract class的注入作为替代方案)
  2. 方便替换,如果我们有一个重构的V2版本的service,只需要更换一下实现类即可。
  3. 方便测试,只需要给到IoC容器一个简单的Mock实例,即可进行测试,不需要考虑内部new实例的副作用。

前置知识

装饰器

这里简单说一下,装饰器实质上就是高阶函数的语法糖,可以作用在类、方法、访问符(getter、setter)和方法参数上,可以获取并修改被作用元素的元信息。具体的用法大全与其我把文档搬过来,不如直接去看官方文档咯~

这里主要用到的是类装饰器和属性装饰器。

  • Component是类装饰器,打在要交给容器管理的类上。

  • AutoWired是属性装饰器,打在需要容器帮忙注入实例的属性上。(暂且只实现属性注入)

元信息

我们在装饰器中,可以拿到被作用元素的一些信息,比如类装饰器中可以拿到类的构造函数、构造函数名字(即类名),这些是默认就有的,也可以给其define一些新的信息,这些信息就称为元信息。我们可以通过定义和读取元信息,得到各个部件的依赖关系。

需求分解

  1. 需要一个IoC容器类来负责创建和注入实例。
  2. 需要@Component装饰器标准要被容器管理的类。
  3. 需要@AutoWired装饰器标注需要自动注入的属性。

假设我们要实现一个获取用户信息的功能,采用Web最常用的Controller -- Service -- Repository架构。

容器实现:

我们维护两个列表,一个注册方法,一个get方法。然后就可以先把容器new出来了。

class Container {
  /**
   * components维护组件列表
   * instances维护实例列表
   */
  components = new Map<string, any>(); // key ->  Constructor
  instances = new Map<string, object>(); // key -> Instance

  /**
   * 注册组件
   * @param constructor 被装饰的类的构造函数
   * @param alias 该组件的名字,默认取类名
   */
  regist(constructor: Function, alias?: string) {
    let name = alias;
    if (!name) {
      name = constructor.name;
    }
    if (this.components.has(name)) {
      console.warn("重复注册Component: " + name);
    }
    this.components.set(name, constructor);
		console.log(this);
  }

  /**
   * 获取实例,实例是懒加载的单例,第一次获取时创建
   * @param alias 组件名字
   */
  get(alias: string) {
    if (this.instances.has(alias)) {
      return this.instances.get(alias);
    }
    const component = this.components.get(alias);
    if (!component) {
      throw "未注册: " + alias;
    }
    const ins = new component();
    this.instances.set(alias, ins);
    console.log(this);
    return ins;
  }
}

const iocContainer = new Container();

@Component实现

这个非常简单,target就是UserRepo的构造函数,拿到构造函数target和别名alias,注册进容器然后将构造函数原样返回即可。

function Component(alias?: string) {
  return function (target: any) {
    iocContainer.regist(target, alias || target.name);
    return target;
  };
}
@Component()
class UserRepo {
  getUserById(id: number) {
    return { user: "jj.zhang", id };
  }
}

然后容器的components会有UserRepo的构造函数。

image.png

@AutoWired实现

function AutoWired(alias?: string) {
  return function (target: any, propertyName: string) {
    let name = alias;
    if (!name) {
      const classConstructor = Reflect.getMetadata(
        "design:type",
        target,
        propertyName
      );
      console.log(99, classConstructor, target, propertyName);
      name = classConstructor.name;
      if (name === "Object") {
        // 没有写类型,则尝试将属性名转大写查找实例
        name = camelcase(propertyName, { pascalCase: true });
      }
    }
    const instance = iocContainer.get(name || "");
    target[propertyName] = instance;
    return instance;
  };
}
@Component()
class UserService {
  @AutoWired()
  userRepo!: UserRepo;

  getUserById(id: number) {
    return this.userRepo.getUserById(id);
  }
}

这里用到Reflect.getMetadata("design:type", target, propertyName)

  • target是UserService的原型对象

  • propertyName是属性的名字,即"userRepo"

  • design:type是内置的元数据key

整句表示获取target["userRepo"]的类型即被装饰属性的构造函数,构造函数的name属性就是我们要的属性类型字符串,也就是UserRepo,然后找容器要一个UserRepo实例,赋值给target["userRepo"]属性。

如果属性没有声明类型,比如这样:

  @AutoWired()
  userRepo;

则name属性拿到的是"Object",此时我们就将属性名propertyName转换为pascalCase作为alias去容器取实例。

最后容器的instances就会多出一个UserRepo的实例了。

image.png

抽象类注入

Java Spring的接口注入是我觉得很精髓的地方,巧妙运用了多态的特性。

但是在TypeScript,装饰器中拿不到接口相关的信息,所以就无法实现接口注入了。

function Component(target: any) {
  // 这里拿不到任何关于TestInterface的信息
}

interface TestInterface {
	test(): string;
}

@Component
class A implements TestInterface {
	test() {
    return 'hello tuya';
  }
}

但是可以使用抽象类实现类似的效果:

  1. 容器新增一个abstracrs列表,维护每个抽象类的所有实现子类。

  2. 新增一个@Impl装饰器,收集每一对抽象类 & 子类,注册到容器,并写入元信息表明这是一个抽象类。

  3. 修改@AutoWired注入逻辑,根据第2步写入的元信息判断,如果属性类型是抽象类,就在该抽象类的所有实现类中,查找和属性名匹配的类实例,进行注入。

最后容器就是这个结构,顺藤摸瓜就可以找到实现类了。

但有意思的事,不管是Nest.js还是Midway的Injection库,都没有提供类似的方案,或许这是个伪需求?

毕竟即使在Java项目中,多数interface从生到死也只有一个Implements😅

Snipaste_2020-11-23_03-18-08.png

abstract class IUserService {
}

@Impl(IUserService)
@Component()
class UserService extends IUserService {
}

@Impl(IUserService)
@Component()
class UserServiceV2 extends IUserService {
}

@Component()
class UserController {
  // // 注入UserService
  // @AutoWired()
  // userService!: IUserService;

  // // 注入UserServiceV2
  // @AutoWired("UserServiceV2")
  // userService!: IUserService;

  // 注入UserService,因为名称转pascal后就是UserService
  @AutoWired()
  userService;
}

总结

以上用最基础的手法,实现了依赖注入的基本原理。还有非常多的特性没有实现,比如构造函数注入、异步初始化等。

实际Component可能在出现在任意文件任意位置,而上面的例子中,如果将AutoWired移到Component前面,就找不到依赖了。

原因有二:

  1. 没有预先扫描所有Component。
  2. 不应该在装饰器中直接创建实例。

实际成熟框架的装饰器只负责写入元信息,不会实例化,由ApplicationContext启动时进行全局包扫描,获取所有的元信息,构建依赖图,确保所有Component都注册到容器中。时间所限这里不再继续展开,有兴趣的大佬可以移步源码Midway包扫描injection源码


来涂鸦工作: job.tuya.com/