TypeScript – 控制反转与依赖注入:基于装饰器的依赖注入实现

TypeScript 全面进阶指南专栏目录总览

上一节学习了装饰器与反射元数据的基本使用后,这一节我们将在其基础上来了解控制反转依赖注入等概念,我们会使用装饰器配合反射元数据实现这一设计模式,以及实现基于装饰器的路由体系与一个简单的控制反转容器。

本节代码见:Decorators

控制反转与依赖注入

控制反转即 Inversion of Control,它是面向对象编程中的一种设计模式,可以用来很好地解耦代码。

由于控制反转出现的时间较晚,因而没有被包括在四人组的设计模式一书当中,但它仍然是一种设计模式。

假设我们存在多个具有依赖关系的类,可能会想当然这么写:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}

现在一共只有三个类,倒还没问题,如果随着开发这些类的数量与依赖关系复杂度暴涨,C 依赖 A B,D 依赖 A C,F 依赖 B C D…,再加上每个类需要实例化的参数可能又有所不同,此时再去手动维护这些依赖关系与实例化过程就是灾难了。

而控制反转模式则能够很好地解决这一问题,它引入了一个容器的概念,内部自动地维护了这些类的依赖关系,当我们需要一个类的时候,它会帮我们把这个类内部依赖的实例都填充好,我们直接用就行:

class F {
  constructor() {
    this.d = Container.get(D);
  }
}

此时,我们的实例 D 已经完成了对 A、C 的依赖填充,C 也完成了 A、B 的依赖填充,也就是说所有复杂的依赖关系都被处理完毕了。

这一模式就叫做控制反转。我们此前手动维护关系的模式则成为控制正转。举个例子,当我们想要处对象时,会上 Soul 这样的交友平台一个一个找,择偶条件是由我自己决定的,这就叫控制正转。现在我觉得这样太麻烦了,直接把自己的介绍、择偶条件上传到世纪佳缘,如果有人认为我不错,就会主动向我发起聊天,而这就是控制反转

控制反转的实现方式主要有两种,依赖查找依赖注入。它们的本质其实均是将依赖关系的维护与创建独立出来

其中依赖查找在 JavaScript 中并不多见,它其实就是将实例化的过程放到了另外一个新的 Factory 方法中:

class Factory {
  static produce(key: string) {
    // ...
  }
}

class F {
  constructor() {
    this.d = Factory.produce("D");
  }
}

在这里,我们的 Factory 类会按照传入的 key 去查找目标对象,然后再进行实例化与赋值过程。而依赖注入的代码则是这样的:

@Provide()
class F {
  @Inject()
  d: D;
}

可以看到这里我们不需要手动进行赋值,只需要声明这个属性,然后使用装饰器标明它需要被注入一个值即可。

这里的 Provide 即标明这个类需要被注册到容器中,如果别的地方需要这个类 F 时,其内部的 d 属性需要被注入一个 D 的实例,而 D 的实例又需要 A、C 的实例等等。这一系列的过程是完全交给容器的,我们需要做的就只是用装饰器简单标明下依赖关系即可。

很明显,相比于依赖查找,依赖注入使用起来更加简洁,几乎不需要额外的业务代码,即不需要一个额外的 Factory 方法去维护实例化逻辑,但其依赖逻辑要更加黑盒。

而装饰器如何实现依赖注入,我想其实你也能 get 到,不就是我们上面所说的元数据吗?比如在属性中通过 Inject 装饰器注册一份元数据,告诉容器这个类的哪些属性需要被注入,然后容器会在内部存储的类里面对应地进行查找。

在部分前端框架中同样大量使用了基于装饰器的依赖注入体系,如 Angular、Nest、MidwayJS 等,目前来看在 NodeJs 框架中的使用要更为常见。如 Nest 与 Midway 中基于装饰器实现了路由、生命周期、模块、中间件与拦截器等等功能,举例来说,基于装饰器的路由可能是这么写的:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {}

  @Post('/add')
  async addUser() {}
}

这么个路由声明意味着,GET /user/list 时会调用 userList 方法,而 POST /user/add 时则会调用 addUser 方法。

学习了依赖注入之后,其实我们也可以来自己实现一个装饰器路由体系!

基于依赖注入的路由实现

本节的代码是我最初在深入浅出 TypeScript 一书中学习到的内容,个人认为非常适合用于加深对依赖注入的理解,因此在其基础上进一步完善后,作为本节的实例代码。

我们的最终目的就是实现上面基于装饰器的路由能力,以及启动一个 Node Server 来完成对这个路由的承接。

分析一下我们需要哪些能力?最重要的就是把每个方法对应的请求路径、请求方法和具体实现绑定起来,也就是在 GET /user/list 时,我们需要调用 userList 方法,并将返回值作为响应。那么,在方法的装饰器 GET POST 上,我们就可以将请求方法、请求路径、方法名、方法实现等信息注册为元数据,然后通过一个统一的提取手段来将它们组装起来。

export enum METADATA_KEY {
  METHOD = 'ioc:method',
  PATH = 'ioc:path',
  MIDDLEWARE = 'ioc:middleware',
}

export enum REQUEST_METHOD {
  GET = 'ioc:get',
  POST = 'ioc:post',
}

export const methodDecoratorFactory = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      // 在方法实现上注册 ioc:method - 请求方法 的元数据
      Reflect.defineMetadata(METADATA_KEY.METHOD, method, descriptor.value!);
      // 在方法实现上注册 ioc:path - 请求路径 的元数据
      Reflect.defineMetadata(METADATA_KEY.PATH, path, descriptor.value!);
    };
  };
};

export const Get = methodDecoratorFactory(REQUEST_METHOD.GET);
export const Post = methodDecoratorFactory(REQUEST_METHOD.POST);

这样一来,@Get("/list") 其实就是注册了 ioc:method - ioc:getioc:path - "list" 这样的两对元数据,分别标识了请求方法与请求路径。需要注意的是,我们是在方法体上去注册的,这样在最终处理时,可以通过这个类的原型拿到方法体,继而获得注册的元数据。

Controller 中就简单一些了,我们只需要拿到它的请求路径信息,然后拼接在这个类中所有请求方法的请求路径前即可:

export const Controller = (path?: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(METADATA_KEY.PATH, path ?? '', target);
  };
};

在最后信息组装时,我们需要做这么几步:

  • 获取根路径,即 Controller 装饰器的入参
  • 获取这个类实例的原型对象
  • 在原型对象上基于方法名获得方法体,继而拿到定义的请求路径、请求方法、请求实现

来看实际代码:

type AsyncFunc = (...args: any[]) => Promise<any>;

interface ICollected {
  path: string;
  requestMethod: string;
  requestHandler: AsyncFunc;
}

export const routerFactory = <T extends object>(ins: T): ICollected[] => {
  const prototype = Reflect.getPrototypeOf(ins) as any;

  const rootPath = <string>(
    Reflect.getMetadata(METADATA_KEY.PATH, prototype.constructor)
  );

  const methods = <string[]>(
    Reflect.ownKeys(prototype).filter((item) => item !== 'constructor')
  );

  const collected = methods.map((m) => {
    const requestHandler = prototype[m];
    const path = <string>Reflect.getMetadata(METADATA_KEY.PATH, requestHandler);

    const requestMethod = <string>(
      Reflect.getMetadata(METADATA_KEY.METHOD, requestHandler).replace(
        'ioc:',
        ''
      )
    );

    return {
      path: `${rootPath}${path}`,
      requestMethod,
      requestHandler,
    };
  });
  return collected;
};

对于开始我们给出的路由使用方法,收集到的最终信息是这样的:

[
  {
    path: '/user/list',
    requestMethod: 'get',
    requestHandler: [AsyncFunction: userList]
  },
  {
    path: '/user/add',
    requestMethod: 'post',
    requestHandler: [AsyncFunction: addUser]
  }
]

现在我们就要来使用一个真正的 Node 服务来检验一下了,直接使用内置的 HTTP 模块启动一个服务器:

import http from 'http';

http
  .createServer((req, res) => {})
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
  });

接下来我们需要做的,就是在 createServer 内去依据请求路径与请求方法调用对应的实现了。我们会遍历收集到的信息,查看是否有某一个对象的路径与请求方法都匹配上了,如果有,就调用这个方法返回:

http
  .createServer((req, res) => {
    for (const info of collected) {
      if (
        req.url === info.path &&
        req.method === info.requestMethod.toLocaleUpperCase()
      ) {
        info.requestHandler().then((data) => {
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify(data));
        });
      }
    }
  })
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
    console.log('GET /user/list at http://localhost:3000/user/list \n');
    console.log('POST /user/add at http://localhost:3000/user/add \n');
  });

在 Controller 中新增简单的方法返回:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {
    return {
      success: true,
      code: 10000,
      data: [
        {
          name: 'linbudu',
          age: 18,
        },
        {
          name: '林不渡',
          age: 28,
        },
      ],
    };
  }

  @Post('/add')
  async addUser() {
    return {
      success: true,
      code: 10000,
    };
  }
}

访问 http://localhost:3000/user/list 来试一下:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

成功了!是不是还有点小激动?你还可以试着加上同样基于装饰器的中间件、拦截器等机制,思路仍然是一致的:注册提取组装以及匹配调用

实际上,在 Nest 这一类框架中,通常会通过完整的容器机制来进行元数据的注册与提取,如 routerFactory(new UserController()) 这一过程,其实就是在你从容器中取出这个类时就已经自动完成了的。那么,我们要如何实现一个如此贴心的容器?

实现一个简易 IoC 容器

实现一个简单的 IoC 容器可以很好地帮助我们总结装饰器、依赖注入、元数据的相关知识,以及理解“控制反转”的本质。

关于这个容器,我们最终想实现的使用方式是这样的:

@Provide()
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get(Car);

car.run(); // 驱动已生效于 Car !

先来梳理一下思路,要实现这么个效果,首先我们需要一个容器,即控制反转中提到的独立的控制方,我们的 Car 依赖于驱动 Driver,这个容器会帮我们完成 Driver 注入到 Car 内的操作。那这个容器如何知道有哪些类需要被提前实例化呢?我们使用一个 Provide 装饰器,被其标记的 Class 会自动被容器收集。然后在需要使用这些类实例的地方,使用 Inject 装饰器声明这里需要哪个实例,容器就会自动地将这个属性注入进来。

这里有一个比较复杂的地方,在存储一个类和注入一个类时,我们需要有一个标识符,才能实现一一对应的注入方式。在上面的例子里我们的 Provide 和 Inject 装饰器都是使用无参数调用的,这样的话标识符从何而来?你可能会想到使用内置的元数据信息!的确是这样,但是为了降低学习成本,我们先来了解如何不使用元数据来实现这个 IoC 容器,也就是我们能够这么使用:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide('Car')
class Car {
  @Inject('DriverService')
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get<Car>('Car')!;

car.run();

这样的话就就简单多了,我们只需要基于字符串来存储、查找、注入一个类就好了。

首先我们创建一个容器,很明显,它需要一个 Map 来以字符串-类的方式存储这些信息,以及 get 与 set 方法:

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

class Container {
  private static services: Map<string, ClassStruct> = new Map();
  
  public static set(key: string, value: ClassStruct): void {}

  public static get<T = any>(key: string): T | undefined {}

  private constructor() {}
}

我们使用私有构造函数来避免这个类被错误地实例化,毕竟它其实只是用来将这些逻辑收拢到一起。

然后就像我们前面说的,Provide 和 Inject 装饰器需要进行存储与注入工作:

function Provide(key: string): ClassDecorator {
  return (Target) => {
    Container.set(key, Target as unknown as ClassStruct);
  };
}

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
   
  };
}

Provide 倒简单,但 Inject 就有些麻烦了,我们在前面提到属性装饰器是无法对类的属性进行操作的,因此我们这里只能使用委托的方式。也就是说,我们先告诉容器有哪些属性需要进行注入,以及需要注入的类的标识符,等我们从容器中去取这个类的时候,容器会帮我们处理这些。

因此容器中需要再增加一个 Map,它的键与键值均为字符串类型:

class Container {
  public static propertyRegistry: Map<string, string> = new Map();
  
}

这样在 Inject 中,我们需要做的就是注册信息:

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key
    );
  };
}

需要注意的是,这里我们注册的是 Car:driver – DriverService 的形式,以此来同时保存这个属性所在的类名称。

接下来,我们需要做的就是 get 与 set 方法了。set 方法简单,直接注册 services 就好:

class Container {
  public static set(key: string, value: ClassStruct): void {
    Container.services.set(key, value);
  }
}

get 方法就要复杂一些了,它需要在我们取出一个类(Container.get('Car'))时,帮我们实例化这个类以及注入这个类内部声明的依赖(DriverService)。整理一下具体步骤:

  • 使用传入的标识符在容器内查找这个类是否已经注册,如果有则进行下一步,没有就返回 undefined。
  • 对于已注册的类,首先将其实例化,然后检查 propertyRegistry ,查看这个类内部是否声明了对外部的依赖?
  • 将这些外部依赖的类从容器中取出(同样通过 get 方法),然后实例化。
  • 将这些实例传递给对应的属性。

我们的大致实现如下:

class Container {
    public static get<T = any>(key: ServiceKey): T | undefined {
    // 检查是否注册
    const Cons = Container.services.get(key);

    if (!Cons) {
      return undefined;
    }

    // 实例化这个类
    const ins = new Cons();

    // 遍历注册信息
    for (const info of Container.propertyRegistry) {
      // 注入标识符与要注入类的标识符
      const [injectKey, serviceKey] = info;
      // 拆分为 Class 名与属性名
      const [classKey, propKey] = injectKey.split(':');

      // 如果不是这个类,就跳过
      if (classKey !== Cons.name) continue;

      // 取出需要注入的类,这里拿到的是已经实例化的
      const target = Container.get(serviceKey);

      if (target) {
        // 赋值给对应的属性
        ins[propKey] = target;
      }
    }

    return ins;
  }
}

来试着调用,会发现已经成功了:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

每次传入字符串的实现肯定不够优雅,我们在使用 Nest、Angular 等框架时,也并不会经常使用字符串作为标识符来实现依赖注入。

可是,如果不使用字符串,我们要用什么来作为标识符呢?聪明的你肯定想到了,可以使用内置的元数据来作为标识符,比如在这种情况下:

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

对于 driver 属性,我们就可以使用它的类型标注 Driver 来作为标识符。那接下来我们来改写上面的容器实现。

基于内置元数据实现

其实最难的一部分我们已经解决了,即如何存储并对应地进行注入,现在要做的不过是升级优化一下,支持在不传入标识符时使用内置元数据作为标识符。首先对 Provide 和 Inject 做改造:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

function Inject(key?: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key ?? Reflect.getMetadata('design:type', target, propertyKey)
    );
  };
}

本节的代码并没有在类型上进行十分精确的处理,这主要是为了避免增加额外的代码复杂度,毕竟我们的主要目的是理解依赖注入而不是类型。

在 Inject 中,我们支持了在不传入标识符时,使用 Reflect.getMetadata('design:type', target, propertyKey) 作为默认的标识符,这里的元数据是一个完整的类,即 Class Driver 。

对应的,为了支持使用 Class 作为标识符进行查找,在 Provide 装饰器中我们需要确保也使用 Class 作为标识符来存储一份:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    // 不论是否传入 key,都使用 Class 作为 key 注册一份
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

然后就没了!我们并不需要修改 Container 的逻辑,只需要调整类型即可:

type ServiceKey<T = any> = string | ClassStruct<T> | Function;

class Container {
  private static services: Map<ServiceKey, ClassStruct> = new Map();

  public static propertyRegistry: Map<string, string> = new Map();

  public static set(key: ServiceKey, value: ClassStruct): void {}

  public static get<T = any>(key: ServiceKey): T | undefined {}
  private constructor() {}
}

现在我们可以同时使用 @Inject() 与 @Inject('DriverService') 这两种方式来实现注入了,来最后测试一下:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Fuel {
  fill(consumer: string) {
    console.log(`\n === 燃料已填充完毕 ${consumer}!===`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  @Inject()
  fule!: Fuel;

  run() {
    this.fule.fill('Car');
    this.driver.adapt('Car');
  }
}

@Provide()
class Bus {
  @Inject('DriverService')
  driver!: Driver;

  @Inject('Fuel')
  fule!: Fuel;

  run() {
    this.fule.fill('Bus');
    this.driver.adapt('Bus');
  }
}

const car = Container.get(Car)!;
const bus = Container.get(Bus)!;

car.run();
bus.run();

image.png

学习完这一节后,请你试着把上一部分的装饰器路由体系也基于这个简单的容器重新实现与改善,如新增对 Service 层与中间件层的注入:

// 如何设计入参?
function logMiddleware() {
    // 中间件逻辑在何时执行?
}

@Controller('/user')
class UserController {
  constructor(@Inject() private userService: UserService) {}
  
  @Middleware(logMiddleware)
  @Get('/list')
  async userList() {
    return await this.userService.all();
  }

  @Post('/add')
  async addUser(user: User) {
    return await this.userService.create(user);
  }
}

总结与预告

在这两节,我们花了相当长的篇幅对装饰器相关的概念与实际应用进行了一次彻底介绍。从装饰器语法到不同类型装饰器的使用,再到反射、反射元数据,最后到控制反转与依赖注入,以及简单的 IoC 路由与 IoC 容器实现。这些概念可以帮助你在使用基于装饰器的工具库时,更加熟悉其底层的原理。同时,如果你想自己开发一些基于装饰器的工具库,这一节的内容也是一个不错的开始。

在接下来两节,我们将投入另一个方面的实战:TSConfig 配置解析。如果你也曾对着一堆配置较劲半天,却没看出个所以然的经历,这一次可以放心了。我们将在下面两节全面解析大部分配置,包括每一条配置的作用、表现以及与它关联的配置们。

扩展阅读

类型严格的装饰器

在这一节的代码中,我们并没有特别关注类型的严格性。实际上装饰器的类型定义也是如此:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

这些类型定义使用的是非常宽泛的类型, 并没有进行对应的约束。而如果将这些类型进行约束,实际上我们就可以实现一个类型严格的装饰器。如我们希望装饰器 @OnlyFoo 只能在 Foo 及其子类上应用,此时就可以通过约束 target 的类型实现:

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

type RestrictedClassDecorator<TClass extends object> = (
  target: ClassStruct<TClass>
) => ClassStruct<TClass> | void;

function OnlyFoo(): RestrictedClassDecorator<Foo> {
  return (target: ClassStruct<Foo>) => {};
}

function OnlyBar(): RestrictedClassDecorator<Bar> {
  return (target: ClassStruct<Bar>) => {};
}

来实际使用一下:

@OnlyFoo()
// 装饰器函数返回类型“void | ClassStruct<Bar>”不可分配到类型“void | typeof Foo”
@OnlyBar()
class Foo {
  foo!: string;
}

@OnlyFoo()
class DerivedFoo extends Foo {
  foo!: string;
}

// 装饰器函数返回类型“void | ClassStruct<Foo>”不可分配到类型“void | typeof Bar”。
@OnlyFoo()
@OnlyBar()
class Bar {
  bar!: string;
}

类似的,我们还可以实现约束方法装饰器只能在同步或异步函数上调用:

type AsyncFunc = (...args: any[]) => Promise<any>;

type OnlyAsyncMethodDecorator = (
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<AsyncFunc>
) => void;

function OnlyAsyncFunc(): OnlyAsyncMethodDecorator {
  return (target, propKey, descriptor) => {};
}

class Foo {
  // 类型“TypedPropertyDescriptor<() => void>”的参数不能赋给类型“TypedPropertyDescriptor<AsyncFunc>”的参数。
  @OnlyAsyncFunc()
  handler() {}

  @OnlyAsyncFunc()
  async asyncHandler() {}
}

以及属性装饰器只用应用在特定类型的属性上:

type LiteralPropertyDecorator = (
  target: Object,
  propertyKey: 'linbudu'
) => void;

function OnlyLiteralProperty(): LiteralPropertyDecorator {
  return (target, propertyKey) => {};
}

type PickByValueType<T, Value> = {
  [Key in keyof T]: T[Key] extends Value ? Key : never;
}[keyof T];

type StringTypePropertyDecorator = <T extends object>(
  target: T,
  propertyKey: PickByValueType<T, string>
) => void;

function OnlyStringTypeProperty(): StringTypePropertyDecorator {
  return (target, propertyKey) => {};
}

class Foo {
  @OnlyStringTypeProperty()
  str!: string;

  // 类型“"bool"”的参数不能赋给类型“PickByValueType<Foo, string>”的参数。
  @OnlyStringTypeProperty()
  bool: boolean = true;

  @OnlyLiteralProperty()
  linbudu!: 'linbudu';
}

这里比较巧妙的是,由于我们只能获取到被装饰的属性名,无法直接获取到其类型,因此通过此前我们学习过的 PickByValueType 工具类型,将这个类上所有符合类型的属性名都提取了出来(作为字面量类型),然后使用这一字面量类型作为类型约束。

上一节学习了装饰器与反射元数据的基本使用后,这一节我们将在其基础上来了解控制反转依赖注入等概念,我们会使用装饰器配合反射元数据实现这一设计模式,以及实现基于装饰器的路由体系与一个简单的控制反转容器。

本节代码见:Decorators

控制反转与依赖注入

控制反转即 Inversion of Control,它是面向对象编程中的一种设计模式,可以用来很好地解耦代码。

由于控制反转出现的时间较晚,因而没有被包括在四人组的设计模式一书当中,但它仍然是一种设计模式。

假设我们存在多个具有依赖关系的类,可能会想当然这么写:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}

现在一共只有三个类,倒还没问题,如果随着开发这些类的数量与依赖关系复杂度暴涨,C 依赖 A B,D 依赖 A C,F 依赖 B C D…,再加上每个类需要实例化的参数可能又有所不同,此时再去手动维护这些依赖关系与实例化过程就是灾难了。

而控制反转模式则能够很好地解决这一问题,它引入了一个容器的概念,内部自动地维护了这些类的依赖关系,当我们需要一个类的时候,它会帮我们把这个类内部依赖的实例都填充好,我们直接用就行:

class F {
  constructor() {
    this.d = Container.get(D);
  }
}

此时,我们的实例 D 已经完成了对 A、C 的依赖填充,C 也完成了 A、B 的依赖填充,也就是说所有复杂的依赖关系都被处理完毕了。

这一模式就叫做控制反转。我们此前手动维护关系的模式则成为控制正转。举个例子,当我们想要处对象时,会上 Soul 这样的交友平台一个一个找,择偶条件是由我自己决定的,这就叫控制正转。现在我觉得这样太麻烦了,直接把自己的介绍、择偶条件上传到世纪佳缘,如果有人认为我不错,就会主动向我发起聊天,而这就是控制反转

控制反转的实现方式主要有两种,依赖查找依赖注入。它们的本质其实均是将依赖关系的维护与创建独立出来

其中依赖查找在 JavaScript 中并不多见,它其实就是将实例化的过程放到了另外一个新的 Factory 方法中:

class Factory {
  static produce(key: string) {
    // ...
  }
}

class F {
  constructor() {
    this.d = Factory.produce("D");
  }
}

在这里,我们的 Factory 类会按照传入的 key 去查找目标对象,然后再进行实例化与赋值过程。而依赖注入的代码则是这样的:

@Provide()
class F {
  @Inject()
  d: D;
}

可以看到这里我们不需要手动进行赋值,只需要声明这个属性,然后使用装饰器标明它需要被注入一个值即可。

这里的 Provide 即标明这个类需要被注册到容器中,如果别的地方需要这个类 F 时,其内部的 d 属性需要被注入一个 D 的实例,而 D 的实例又需要 A、C 的实例等等。这一系列的过程是完全交给容器的,我们需要做的就只是用装饰器简单标明下依赖关系即可。

很明显,相比于依赖查找,依赖注入使用起来更加简洁,几乎不需要额外的业务代码,即不需要一个额外的 Factory 方法去维护实例化逻辑,但其依赖逻辑要更加黑盒。

而装饰器如何实现依赖注入,我想其实你也能 get 到,不就是我们上面所说的元数据吗?比如在属性中通过 Inject 装饰器注册一份元数据,告诉容器这个类的哪些属性需要被注入,然后容器会在内部存储的类里面对应地进行查找。

在部分前端框架中同样大量使用了基于装饰器的依赖注入体系,如 Angular、Nest、MidwayJS 等,目前来看在 NodeJs 框架中的使用要更为常见。如 Nest 与 Midway 中基于装饰器实现了路由、生命周期、模块、中间件与拦截器等等功能,举例来说,基于装饰器的路由可能是这么写的:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {}

  @Post('/add')
  async addUser() {}
}

这么个路由声明意味着,GET /user/list 时会调用 userList 方法,而 POST /user/add 时则会调用 addUser 方法。

学习了依赖注入之后,其实我们也可以来自己实现一个装饰器路由体系!

基于依赖注入的路由实现

本节的代码是我最初在深入浅出 TypeScript 一书中学习到的内容,个人认为非常适合用于加深对依赖注入的理解,因此在其基础上进一步完善后,作为本节的实例代码。

我们的最终目的就是实现上面基于装饰器的路由能力,以及启动一个 Node Server 来完成对这个路由的承接。

分析一下我们需要哪些能力?最重要的就是把每个方法对应的请求路径、请求方法和具体实现绑定起来,也就是在 GET /user/list 时,我们需要调用 userList 方法,并将返回值作为响应。那么,在方法的装饰器 GET POST 上,我们就可以将请求方法、请求路径、方法名、方法实现等信息注册为元数据,然后通过一个统一的提取手段来将它们组装起来。

export enum METADATA_KEY {
  METHOD = 'ioc:method',
  PATH = 'ioc:path',
  MIDDLEWARE = 'ioc:middleware',
}

export enum REQUEST_METHOD {
  GET = 'ioc:get',
  POST = 'ioc:post',
}

export const methodDecoratorFactory = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      // 在方法实现上注册 ioc:method - 请求方法 的元数据
      Reflect.defineMetadata(METADATA_KEY.METHOD, method, descriptor.value!);
      // 在方法实现上注册 ioc:path - 请求路径 的元数据
      Reflect.defineMetadata(METADATA_KEY.PATH, path, descriptor.value!);
    };
  };
};

export const Get = methodDecoratorFactory(REQUEST_METHOD.GET);
export const Post = methodDecoratorFactory(REQUEST_METHOD.POST);

这样一来,@Get("/list") 其实就是注册了 ioc:method - ioc:getioc:path - "list" 这样的两对元数据,分别标识了请求方法与请求路径。需要注意的是,我们是在方法体上去注册的,这样在最终处理时,可以通过这个类的原型拿到方法体,继而获得注册的元数据。

Controller 中就简单一些了,我们只需要拿到它的请求路径信息,然后拼接在这个类中所有请求方法的请求路径前即可:

export const Controller = (path?: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(METADATA_KEY.PATH, path ?? '', target);
  };
};

在最后信息组装时,我们需要做这么几步:

  • 获取根路径,即 Controller 装饰器的入参
  • 获取这个类实例的原型对象
  • 在原型对象上基于方法名获得方法体,继而拿到定义的请求路径、请求方法、请求实现

来看实际代码:

type AsyncFunc = (...args: any[]) => Promise<any>;

interface ICollected {
  path: string;
  requestMethod: string;
  requestHandler: AsyncFunc;
}

export const routerFactory = <T extends object>(ins: T): ICollected[] => {
  const prototype = Reflect.getPrototypeOf(ins) as any;

  const rootPath = <string>(
    Reflect.getMetadata(METADATA_KEY.PATH, prototype.constructor)
  );

  const methods = <string[]>(
    Reflect.ownKeys(prototype).filter((item) => item !== 'constructor')
  );

  const collected = methods.map((m) => {
    const requestHandler = prototype[m];
    const path = <string>Reflect.getMetadata(METADATA_KEY.PATH, requestHandler);

    const requestMethod = <string>(
      Reflect.getMetadata(METADATA_KEY.METHOD, requestHandler).replace(
        'ioc:',
        ''
      )
    );

    return {
      path: `${rootPath}${path}`,
      requestMethod,
      requestHandler,
    };
  });
  return collected;
};

对于开始我们给出的路由使用方法,收集到的最终信息是这样的:

[
  {
    path: '/user/list',
    requestMethod: 'get',
    requestHandler: [AsyncFunction: userList]
  },
  {
    path: '/user/add',
    requestMethod: 'post',
    requestHandler: [AsyncFunction: addUser]
  }
]

现在我们就要来使用一个真正的 Node 服务来检验一下了,直接使用内置的 HTTP 模块启动一个服务器:

import http from 'http';

http
  .createServer((req, res) => {})
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
  });

接下来我们需要做的,就是在 createServer 内去依据请求路径与请求方法调用对应的实现了。我们会遍历收集到的信息,查看是否有某一个对象的路径与请求方法都匹配上了,如果有,就调用这个方法返回:

http
  .createServer((req, res) => {
    for (const info of collected) {
      if (
        req.url === info.path &&
        req.method === info.requestMethod.toLocaleUpperCase()
      ) {
        info.requestHandler().then((data) => {
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify(data));
        });
      }
    }
  })
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
    console.log('GET /user/list at http://localhost:3000/user/list \n');
    console.log('POST /user/add at http://localhost:3000/user/add \n');
  });

在 Controller 中新增简单的方法返回:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {
    return {
      success: true,
      code: 10000,
      data: [
        {
          name: 'linbudu',
          age: 18,
        },
        {
          name: '林不渡',
          age: 28,
        },
      ],
    };
  }

  @Post('/add')
  async addUser() {
    return {
      success: true,
      code: 10000,
    };
  }
}

访问 http://localhost:3000/user/list 来试一下:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

成功了!是不是还有点小激动?你还可以试着加上同样基于装饰器的中间件、拦截器等机制,思路仍然是一致的:注册提取组装以及匹配调用

实际上,在 Nest 这一类框架中,通常会通过完整的容器机制来进行元数据的注册与提取,如 routerFactory(new UserController()) 这一过程,其实就是在你从容器中取出这个类时就已经自动完成了的。那么,我们要如何实现一个如此贴心的容器?

实现一个简易 IoC 容器

实现一个简单的 IoC 容器可以很好地帮助我们总结装饰器、依赖注入、元数据的相关知识,以及理解“控制反转”的本质。

关于这个容器,我们最终想实现的使用方式是这样的:

@Provide()
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get(Car);

car.run(); // 驱动已生效于 Car !

先来梳理一下思路,要实现这么个效果,首先我们需要一个容器,即控制反转中提到的独立的控制方,我们的 Car 依赖于驱动 Driver,这个容器会帮我们完成 Driver 注入到 Car 内的操作。那这个容器如何知道有哪些类需要被提前实例化呢?我们使用一个 Provide 装饰器,被其标记的 Class 会自动被容器收集。然后在需要使用这些类实例的地方,使用 Inject 装饰器声明这里需要哪个实例,容器就会自动地将这个属性注入进来。

这里有一个比较复杂的地方,在存储一个类和注入一个类时,我们需要有一个标识符,才能实现一一对应的注入方式。在上面的例子里我们的 Provide 和 Inject 装饰器都是使用无参数调用的,这样的话标识符从何而来?你可能会想到使用内置的元数据信息!的确是这样,但是为了降低学习成本,我们先来了解如何不使用元数据来实现这个 IoC 容器,也就是我们能够这么使用:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide('Car')
class Car {
  @Inject('DriverService')
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get<Car>('Car')!;

car.run();

这样的话就就简单多了,我们只需要基于字符串来存储、查找、注入一个类就好了。

首先我们创建一个容器,很明显,它需要一个 Map 来以字符串-类的方式存储这些信息,以及 get 与 set 方法:

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

class Container {
  private static services: Map<string, ClassStruct> = new Map();
  
  public static set(key: string, value: ClassStruct): void {}

  public static get<T = any>(key: string): T | undefined {}

  private constructor() {}
}

我们使用私有构造函数来避免这个类被错误地实例化,毕竟它其实只是用来将这些逻辑收拢到一起。

然后就像我们前面说的,Provide 和 Inject 装饰器需要进行存储与注入工作:

function Provide(key: string): ClassDecorator {
  return (Target) => {
    Container.set(key, Target as unknown as ClassStruct);
  };
}

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
   
  };
}

Provide 倒简单,但 Inject 就有些麻烦了,我们在前面提到属性装饰器是无法对类的属性进行操作的,因此我们这里只能使用委托的方式。也就是说,我们先告诉容器有哪些属性需要进行注入,以及需要注入的类的标识符,等我们从容器中去取这个类的时候,容器会帮我们处理这些。

因此容器中需要再增加一个 Map,它的键与键值均为字符串类型:

class Container {
  public static propertyRegistry: Map<string, string> = new Map();
  
}

这样在 Inject 中,我们需要做的就是注册信息:

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key
    );
  };
}

需要注意的是,这里我们注册的是 Car:driver – DriverService 的形式,以此来同时保存这个属性所在的类名称。

接下来,我们需要做的就是 get 与 set 方法了。set 方法简单,直接注册 services 就好:

class Container {
  public static set(key: string, value: ClassStruct): void {
    Container.services.set(key, value);
  }
}

get 方法就要复杂一些了,它需要在我们取出一个类(Container.get('Car'))时,帮我们实例化这个类以及注入这个类内部声明的依赖(DriverService)。整理一下具体步骤:

  • 使用传入的标识符在容器内查找这个类是否已经注册,如果有则进行下一步,没有就返回 undefined。
  • 对于已注册的类,首先将其实例化,然后检查 propertyRegistry ,查看这个类内部是否声明了对外部的依赖?
  • 将这些外部依赖的类从容器中取出(同样通过 get 方法),然后实例化。
  • 将这些实例传递给对应的属性。

我们的大致实现如下:

class Container {
    public static get<T = any>(key: ServiceKey): T | undefined {
    // 检查是否注册
    const Cons = Container.services.get(key);

    if (!Cons) {
      return undefined;
    }

    // 实例化这个类
    const ins = new Cons();

    // 遍历注册信息
    for (const info of Container.propertyRegistry) {
      // 注入标识符与要注入类的标识符
      const [injectKey, serviceKey] = info;
      // 拆分为 Class 名与属性名
      const [classKey, propKey] = injectKey.split(':');

      // 如果不是这个类,就跳过
      if (classKey !== Cons.name) continue;

      // 取出需要注入的类,这里拿到的是已经实例化的
      const target = Container.get(serviceKey);

      if (target) {
        // 赋值给对应的属性
        ins[propKey] = target;
      }
    }

    return ins;
  }
}

来试着调用,会发现已经成功了:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

每次传入字符串的实现肯定不够优雅,我们在使用 Nest、Angular 等框架时,也并不会经常使用字符串作为标识符来实现依赖注入。

可是,如果不使用字符串,我们要用什么来作为标识符呢?聪明的你肯定想到了,可以使用内置的元数据来作为标识符,比如在这种情况下:

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

对于 driver 属性,我们就可以使用它的类型标注 Driver 来作为标识符。那接下来我们来改写上面的容器实现。

基于内置元数据实现

其实最难的一部分我们已经解决了,即如何存储并对应地进行注入,现在要做的不过是升级优化一下,支持在不传入标识符时使用内置元数据作为标识符。首先对 Provide 和 Inject 做改造:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

function Inject(key?: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key ?? Reflect.getMetadata('design:type', target, propertyKey)
    );
  };
}

本节的代码并没有在类型上进行十分精确的处理,这主要是为了避免增加额外的代码复杂度,毕竟我们的主要目的是理解依赖注入而不是类型。

在 Inject 中,我们支持了在不传入标识符时,使用 Reflect.getMetadata('design:type', target, propertyKey) 作为默认的标识符,这里的元数据是一个完整的类,即 Class Driver 。

对应的,为了支持使用 Class 作为标识符进行查找,在 Provide 装饰器中我们需要确保也使用 Class 作为标识符来存储一份:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    // 不论是否传入 key,都使用 Class 作为 key 注册一份
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

然后就没了!我们并不需要修改 Container 的逻辑,只需要调整类型即可:

type ServiceKey<T = any> = string | ClassStruct<T> | Function;

class Container {
  private static services: Map<ServiceKey, ClassStruct> = new Map();

  public static propertyRegistry: Map<string, string> = new Map();

  public static set(key: ServiceKey, value: ClassStruct): void {}

  public static get<T = any>(key: ServiceKey): T | undefined {}
  private constructor() {}
}

现在我们可以同时使用 @Inject() 与 @Inject('DriverService') 这两种方式来实现注入了,来最后测试一下:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Fuel {
  fill(consumer: string) {
    console.log(`\n === 燃料已填充完毕 ${consumer}!===`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  @Inject()
  fule!: Fuel;

  run() {
    this.fule.fill('Car');
    this.driver.adapt('Car');
  }
}

@Provide()
class Bus {
  @Inject('DriverService')
  driver!: Driver;

  @Inject('Fuel')
  fule!: Fuel;

  run() {
    this.fule.fill('Bus');
    this.driver.adapt('Bus');
  }
}

const car = Container.get(Car)!;
const bus = Container.get(Bus)!;

car.run();
bus.run();

image.png

学习完这一节后,请你试着把上一部分的装饰器路由体系也基于这个简单的容器重新实现与改善,如新增对 Service 层与中间件层的注入:

// 如何设计入参?
function logMiddleware() {
    // 中间件逻辑在何时执行?
}

@Controller('/user')
class UserController {
  constructor(@Inject() private userService: UserService) {}
  
  @Middleware(logMiddleware)
  @Get('/list')
  async userList() {
    return await this.userService.all();
  }

  @Post('/add')
  async addUser(user: User) {
    return await this.userService.create(user);
  }
}

总结与预告

在这两节,我们花了相当长的篇幅对装饰器相关的概念与实际应用进行了一次彻底介绍。从装饰器语法到不同类型装饰器的使用,再到反射、反射元数据,最后到控制反转与依赖注入,以及简单的 IoC 路由与 IoC 容器实现。这些概念可以帮助你在使用基于装饰器的工具库时,更加熟悉其底层的原理。同时,如果你想自己开发一些基于装饰器的工具库,这一节的内容也是一个不错的开始。

在接下来两节,我们将投入另一个方面的实战:TSConfig 配置解析。如果你也曾对着一堆配置较劲半天,却没看出个所以然的经历,这一次可以放心了。我们将在下面两节全面解析大部分配置,包括每一条配置的作用、表现以及与它关联的配置们。

扩展阅读

类型严格的装饰器

在这一节的代码中,我们并没有特别关注类型的严格性。实际上装饰器的类型定义也是如此:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

这些类型定义使用的是非常宽泛的类型, 并没有进行对应的约束。而如果将这些类型进行约束,实际上我们就可以实现一个类型严格的装饰器。如我们希望装饰器 @OnlyFoo 只能在 Foo 及其子类上应用,此时就可以通过约束 target 的类型实现:

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

type RestrictedClassDecorator<TClass extends object> = (
  target: ClassStruct<TClass>
) => ClassStruct<TClass> | void;

function OnlyFoo(): RestrictedClassDecorator<Foo> {
  return (target: ClassStruct<Foo>) => {};
}

function OnlyBar(): RestrictedClassDecorator<Bar> {
  return (target: ClassStruct<Bar>) => {};
}

来实际使用一下:

@OnlyFoo()
// 装饰器函数返回类型“void | ClassStruct<Bar>”不可分配到类型“void | typeof Foo”
@OnlyBar()
class Foo {
  foo!: string;
}

@OnlyFoo()
class DerivedFoo extends Foo {
  foo!: string;
}

// 装饰器函数返回类型“void | ClassStruct<Foo>”不可分配到类型“void | typeof Bar”。
@OnlyFoo()
@OnlyBar()
class Bar {
  bar!: string;
}

类似的,我们还可以实现约束方法装饰器只能在同步或异步函数上调用:

type AsyncFunc = (...args: any[]) => Promise<any>;

type OnlyAsyncMethodDecorator = (
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<AsyncFunc>
) => void;

function OnlyAsyncFunc(): OnlyAsyncMethodDecorator {
  return (target, propKey, descriptor) => {};
}

class Foo {
  // 类型“TypedPropertyDescriptor<() => void>”的参数不能赋给类型“TypedPropertyDescriptor<AsyncFunc>”的参数。
  @OnlyAsyncFunc()
  handler() {}

  @OnlyAsyncFunc()
  async asyncHandler() {}
}

以及属性装饰器只用应用在特定类型的属性上:

type LiteralPropertyDecorator = (
  target: Object,
  propertyKey: 'linbudu'
) => void;

function OnlyLiteralProperty(): LiteralPropertyDecorator {
  return (target, propertyKey) => {};
}

type PickByValueType<T, Value> = {
  [Key in keyof T]: T[Key] extends Value ? Key : never;
}[keyof T];

type StringTypePropertyDecorator = <T extends object>(
  target: T,
  propertyKey: PickByValueType<T, string>
) => void;

function OnlyStringTypeProperty(): StringTypePropertyDecorator {
  return (target, propertyKey) => {};
}

class Foo {
  @OnlyStringTypeProperty()
  str!: string;

  // 类型“"bool"”的参数不能赋给类型“PickByValueType<Foo, string>”的参数。
  @OnlyStringTypeProperty()
  bool: boolean = true;

  @OnlyLiteralProperty()
  linbudu!: 'linbudu';
}

这里比较巧妙的是,由于我们只能获取到被装饰的属性名,无法直接获取到其类型,因此通过此前我们学习过的 PickByValueType 工具类型,将这个类上所有符合类型的属性名都提取了出来(作为字面量类型),然后使用这一字面量类型作为类型约束。

上一节学习了装饰器与反射元数据的基本使用后,这一节我们将在其基础上来了解控制反转依赖注入等概念,我们会使用装饰器配合反射元数据实现这一设计模式,以及实现基于装饰器的路由体系与一个简单的控制反转容器。

本节代码见:Decorators

控制反转与依赖注入

控制反转即 Inversion of Control,它是面向对象编程中的一种设计模式,可以用来很好地解耦代码。

由于控制反转出现的时间较晚,因而没有被包括在四人组的设计模式一书当中,但它仍然是一种设计模式。

假设我们存在多个具有依赖关系的类,可能会想当然这么写:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}

现在一共只有三个类,倒还没问题,如果随着开发这些类的数量与依赖关系复杂度暴涨,C 依赖 A B,D 依赖 A C,F 依赖 B C D…,再加上每个类需要实例化的参数可能又有所不同,此时再去手动维护这些依赖关系与实例化过程就是灾难了。

而控制反转模式则能够很好地解决这一问题,它引入了一个容器的概念,内部自动地维护了这些类的依赖关系,当我们需要一个类的时候,它会帮我们把这个类内部依赖的实例都填充好,我们直接用就行:

class F {
  constructor() {
    this.d = Container.get(D);
  }
}

此时,我们的实例 D 已经完成了对 A、C 的依赖填充,C 也完成了 A、B 的依赖填充,也就是说所有复杂的依赖关系都被处理完毕了。

这一模式就叫做控制反转。我们此前手动维护关系的模式则成为控制正转。举个例子,当我们想要处对象时,会上 Soul 这样的交友平台一个一个找,择偶条件是由我自己决定的,这就叫控制正转。现在我觉得这样太麻烦了,直接把自己的介绍、择偶条件上传到世纪佳缘,如果有人认为我不错,就会主动向我发起聊天,而这就是控制反转

控制反转的实现方式主要有两种,依赖查找依赖注入。它们的本质其实均是将依赖关系的维护与创建独立出来

其中依赖查找在 JavaScript 中并不多见,它其实就是将实例化的过程放到了另外一个新的 Factory 方法中:

class Factory {
  static produce(key: string) {
    // ...
  }
}

class F {
  constructor() {
    this.d = Factory.produce("D");
  }
}

在这里,我们的 Factory 类会按照传入的 key 去查找目标对象,然后再进行实例化与赋值过程。而依赖注入的代码则是这样的:

@Provide()
class F {
  @Inject()
  d: D;
}

可以看到这里我们不需要手动进行赋值,只需要声明这个属性,然后使用装饰器标明它需要被注入一个值即可。

这里的 Provide 即标明这个类需要被注册到容器中,如果别的地方需要这个类 F 时,其内部的 d 属性需要被注入一个 D 的实例,而 D 的实例又需要 A、C 的实例等等。这一系列的过程是完全交给容器的,我们需要做的就只是用装饰器简单标明下依赖关系即可。

很明显,相比于依赖查找,依赖注入使用起来更加简洁,几乎不需要额外的业务代码,即不需要一个额外的 Factory 方法去维护实例化逻辑,但其依赖逻辑要更加黑盒。

而装饰器如何实现依赖注入,我想其实你也能 get 到,不就是我们上面所说的元数据吗?比如在属性中通过 Inject 装饰器注册一份元数据,告诉容器这个类的哪些属性需要被注入,然后容器会在内部存储的类里面对应地进行查找。

在部分前端框架中同样大量使用了基于装饰器的依赖注入体系,如 Angular、Nest、MidwayJS 等,目前来看在 NodeJs 框架中的使用要更为常见。如 Nest 与 Midway 中基于装饰器实现了路由、生命周期、模块、中间件与拦截器等等功能,举例来说,基于装饰器的路由可能是这么写的:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {}

  @Post('/add')
  async addUser() {}
}

这么个路由声明意味着,GET /user/list 时会调用 userList 方法,而 POST /user/add 时则会调用 addUser 方法。

学习了依赖注入之后,其实我们也可以来自己实现一个装饰器路由体系!

基于依赖注入的路由实现

本节的代码是我最初在深入浅出 TypeScript 一书中学习到的内容,个人认为非常适合用于加深对依赖注入的理解,因此在其基础上进一步完善后,作为本节的实例代码。

我们的最终目的就是实现上面基于装饰器的路由能力,以及启动一个 Node Server 来完成对这个路由的承接。

分析一下我们需要哪些能力?最重要的就是把每个方法对应的请求路径、请求方法和具体实现绑定起来,也就是在 GET /user/list 时,我们需要调用 userList 方法,并将返回值作为响应。那么,在方法的装饰器 GET POST 上,我们就可以将请求方法、请求路径、方法名、方法实现等信息注册为元数据,然后通过一个统一的提取手段来将它们组装起来。

export enum METADATA_KEY {
  METHOD = 'ioc:method',
  PATH = 'ioc:path',
  MIDDLEWARE = 'ioc:middleware',
}

export enum REQUEST_METHOD {
  GET = 'ioc:get',
  POST = 'ioc:post',
}

export const methodDecoratorFactory = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      // 在方法实现上注册 ioc:method - 请求方法 的元数据
      Reflect.defineMetadata(METADATA_KEY.METHOD, method, descriptor.value!);
      // 在方法实现上注册 ioc:path - 请求路径 的元数据
      Reflect.defineMetadata(METADATA_KEY.PATH, path, descriptor.value!);
    };
  };
};

export const Get = methodDecoratorFactory(REQUEST_METHOD.GET);
export const Post = methodDecoratorFactory(REQUEST_METHOD.POST);

这样一来,@Get("/list") 其实就是注册了 ioc:method - ioc:getioc:path - "list" 这样的两对元数据,分别标识了请求方法与请求路径。需要注意的是,我们是在方法体上去注册的,这样在最终处理时,可以通过这个类的原型拿到方法体,继而获得注册的元数据。

Controller 中就简单一些了,我们只需要拿到它的请求路径信息,然后拼接在这个类中所有请求方法的请求路径前即可:

export const Controller = (path?: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(METADATA_KEY.PATH, path ?? '', target);
  };
};

在最后信息组装时,我们需要做这么几步:

  • 获取根路径,即 Controller 装饰器的入参
  • 获取这个类实例的原型对象
  • 在原型对象上基于方法名获得方法体,继而拿到定义的请求路径、请求方法、请求实现

来看实际代码:

type AsyncFunc = (...args: any[]) => Promise<any>;

interface ICollected {
  path: string;
  requestMethod: string;
  requestHandler: AsyncFunc;
}

export const routerFactory = <T extends object>(ins: T): ICollected[] => {
  const prototype = Reflect.getPrototypeOf(ins) as any;

  const rootPath = <string>(
    Reflect.getMetadata(METADATA_KEY.PATH, prototype.constructor)
  );

  const methods = <string[]>(
    Reflect.ownKeys(prototype).filter((item) => item !== 'constructor')
  );

  const collected = methods.map((m) => {
    const requestHandler = prototype[m];
    const path = <string>Reflect.getMetadata(METADATA_KEY.PATH, requestHandler);

    const requestMethod = <string>(
      Reflect.getMetadata(METADATA_KEY.METHOD, requestHandler).replace(
        'ioc:',
        ''
      )
    );

    return {
      path: `${rootPath}${path}`,
      requestMethod,
      requestHandler,
    };
  });
  return collected;
};

对于开始我们给出的路由使用方法,收集到的最终信息是这样的:

[
  {
    path: '/user/list',
    requestMethod: 'get',
    requestHandler: [AsyncFunction: userList]
  },
  {
    path: '/user/add',
    requestMethod: 'post',
    requestHandler: [AsyncFunction: addUser]
  }
]

现在我们就要来使用一个真正的 Node 服务来检验一下了,直接使用内置的 HTTP 模块启动一个服务器:

import http from 'http';

http
  .createServer((req, res) => {})
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
  });

接下来我们需要做的,就是在 createServer 内去依据请求路径与请求方法调用对应的实现了。我们会遍历收集到的信息,查看是否有某一个对象的路径与请求方法都匹配上了,如果有,就调用这个方法返回:

http
  .createServer((req, res) => {
    for (const info of collected) {
      if (
        req.url === info.path &&
        req.method === info.requestMethod.toLocaleUpperCase()
      ) {
        info.requestHandler().then((data) => {
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify(data));
        });
      }
    }
  })
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
    console.log('GET /user/list at http://localhost:3000/user/list \n');
    console.log('POST /user/add at http://localhost:3000/user/add \n');
  });

在 Controller 中新增简单的方法返回:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {
    return {
      success: true,
      code: 10000,
      data: [
        {
          name: 'linbudu',
          age: 18,
        },
        {
          name: '林不渡',
          age: 28,
        },
      ],
    };
  }

  @Post('/add')
  async addUser() {
    return {
      success: true,
      code: 10000,
    };
  }
}

访问 http://localhost:3000/user/list 来试一下:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

成功了!是不是还有点小激动?你还可以试着加上同样基于装饰器的中间件、拦截器等机制,思路仍然是一致的:注册提取组装以及匹配调用

实际上,在 Nest 这一类框架中,通常会通过完整的容器机制来进行元数据的注册与提取,如 routerFactory(new UserController()) 这一过程,其实就是在你从容器中取出这个类时就已经自动完成了的。那么,我们要如何实现一个如此贴心的容器?

实现一个简易 IoC 容器

实现一个简单的 IoC 容器可以很好地帮助我们总结装饰器、依赖注入、元数据的相关知识,以及理解“控制反转”的本质。

关于这个容器,我们最终想实现的使用方式是这样的:

@Provide()
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get(Car);

car.run(); // 驱动已生效于 Car !

先来梳理一下思路,要实现这么个效果,首先我们需要一个容器,即控制反转中提到的独立的控制方,我们的 Car 依赖于驱动 Driver,这个容器会帮我们完成 Driver 注入到 Car 内的操作。那这个容器如何知道有哪些类需要被提前实例化呢?我们使用一个 Provide 装饰器,被其标记的 Class 会自动被容器收集。然后在需要使用这些类实例的地方,使用 Inject 装饰器声明这里需要哪个实例,容器就会自动地将这个属性注入进来。

这里有一个比较复杂的地方,在存储一个类和注入一个类时,我们需要有一个标识符,才能实现一一对应的注入方式。在上面的例子里我们的 Provide 和 Inject 装饰器都是使用无参数调用的,这样的话标识符从何而来?你可能会想到使用内置的元数据信息!的确是这样,但是为了降低学习成本,我们先来了解如何不使用元数据来实现这个 IoC 容器,也就是我们能够这么使用:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide('Car')
class Car {
  @Inject('DriverService')
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get<Car>('Car')!;

car.run();

这样的话就就简单多了,我们只需要基于字符串来存储、查找、注入一个类就好了。

首先我们创建一个容器,很明显,它需要一个 Map 来以字符串-类的方式存储这些信息,以及 get 与 set 方法:

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

class Container {
  private static services: Map<string, ClassStruct> = new Map();
  
  public static set(key: string, value: ClassStruct): void {}

  public static get<T = any>(key: string): T | undefined {}

  private constructor() {}
}

我们使用私有构造函数来避免这个类被错误地实例化,毕竟它其实只是用来将这些逻辑收拢到一起。

然后就像我们前面说的,Provide 和 Inject 装饰器需要进行存储与注入工作:

function Provide(key: string): ClassDecorator {
  return (Target) => {
    Container.set(key, Target as unknown as ClassStruct);
  };
}

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
   
  };
}

Provide 倒简单,但 Inject 就有些麻烦了,我们在前面提到属性装饰器是无法对类的属性进行操作的,因此我们这里只能使用委托的方式。也就是说,我们先告诉容器有哪些属性需要进行注入,以及需要注入的类的标识符,等我们从容器中去取这个类的时候,容器会帮我们处理这些。

因此容器中需要再增加一个 Map,它的键与键值均为字符串类型:

class Container {
  public static propertyRegistry: Map<string, string> = new Map();
  
}

这样在 Inject 中,我们需要做的就是注册信息:

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key
    );
  };
}

需要注意的是,这里我们注册的是 Car:driver – DriverService 的形式,以此来同时保存这个属性所在的类名称。

接下来,我们需要做的就是 get 与 set 方法了。set 方法简单,直接注册 services 就好:

class Container {
  public static set(key: string, value: ClassStruct): void {
    Container.services.set(key, value);
  }
}

get 方法就要复杂一些了,它需要在我们取出一个类(Container.get('Car'))时,帮我们实例化这个类以及注入这个类内部声明的依赖(DriverService)。整理一下具体步骤:

  • 使用传入的标识符在容器内查找这个类是否已经注册,如果有则进行下一步,没有就返回 undefined。
  • 对于已注册的类,首先将其实例化,然后检查 propertyRegistry ,查看这个类内部是否声明了对外部的依赖?
  • 将这些外部依赖的类从容器中取出(同样通过 get 方法),然后实例化。
  • 将这些实例传递给对应的属性。

我们的大致实现如下:

class Container {
    public static get<T = any>(key: ServiceKey): T | undefined {
    // 检查是否注册
    const Cons = Container.services.get(key);

    if (!Cons) {
      return undefined;
    }

    // 实例化这个类
    const ins = new Cons();

    // 遍历注册信息
    for (const info of Container.propertyRegistry) {
      // 注入标识符与要注入类的标识符
      const [injectKey, serviceKey] = info;
      // 拆分为 Class 名与属性名
      const [classKey, propKey] = injectKey.split(':');

      // 如果不是这个类,就跳过
      if (classKey !== Cons.name) continue;

      // 取出需要注入的类,这里拿到的是已经实例化的
      const target = Container.get(serviceKey);

      if (target) {
        // 赋值给对应的属性
        ins[propKey] = target;
      }
    }

    return ins;
  }
}

来试着调用,会发现已经成功了:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

每次传入字符串的实现肯定不够优雅,我们在使用 Nest、Angular 等框架时,也并不会经常使用字符串作为标识符来实现依赖注入。

可是,如果不使用字符串,我们要用什么来作为标识符呢?聪明的你肯定想到了,可以使用内置的元数据来作为标识符,比如在这种情况下:

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

对于 driver 属性,我们就可以使用它的类型标注 Driver 来作为标识符。那接下来我们来改写上面的容器实现。

基于内置元数据实现

其实最难的一部分我们已经解决了,即如何存储并对应地进行注入,现在要做的不过是升级优化一下,支持在不传入标识符时使用内置元数据作为标识符。首先对 Provide 和 Inject 做改造:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

function Inject(key?: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key ?? Reflect.getMetadata('design:type', target, propertyKey)
    );
  };
}

本节的代码并没有在类型上进行十分精确的处理,这主要是为了避免增加额外的代码复杂度,毕竟我们的主要目的是理解依赖注入而不是类型。

在 Inject 中,我们支持了在不传入标识符时,使用 Reflect.getMetadata('design:type', target, propertyKey) 作为默认的标识符,这里的元数据是一个完整的类,即 Class Driver 。

对应的,为了支持使用 Class 作为标识符进行查找,在 Provide 装饰器中我们需要确保也使用 Class 作为标识符来存储一份:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    // 不论是否传入 key,都使用 Class 作为 key 注册一份
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

然后就没了!我们并不需要修改 Container 的逻辑,只需要调整类型即可:

type ServiceKey<T = any> = string | ClassStruct<T> | Function;

class Container {
  private static services: Map<ServiceKey, ClassStruct> = new Map();

  public static propertyRegistry: Map<string, string> = new Map();

  public static set(key: ServiceKey, value: ClassStruct): void {}

  public static get<T = any>(key: ServiceKey): T | undefined {}
  private constructor() {}
}

现在我们可以同时使用 @Inject() 与 @Inject('DriverService') 这两种方式来实现注入了,来最后测试一下:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Fuel {
  fill(consumer: string) {
    console.log(`\n === 燃料已填充完毕 ${consumer}!===`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  @Inject()
  fule!: Fuel;

  run() {
    this.fule.fill('Car');
    this.driver.adapt('Car');
  }
}

@Provide()
class Bus {
  @Inject('DriverService')
  driver!: Driver;

  @Inject('Fuel')
  fule!: Fuel;

  run() {
    this.fule.fill('Bus');
    this.driver.adapt('Bus');
  }
}

const car = Container.get(Car)!;
const bus = Container.get(Bus)!;

car.run();
bus.run();

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

学习完这一节后,请你试着把上一部分的装饰器路由体系也基于这个简单的容器重新实现与改善,如新增对 Service 层与中间件层的注入:

// 如何设计入参?
function logMiddleware() {
    // 中间件逻辑在何时执行?
}

@Controller('/user')
class UserController {
  constructor(@Inject() private userService: UserService) {}
  
  @Middleware(logMiddleware)
  @Get('/list')
  async userList() {
    return await this.userService.all();
  }

  @Post('/add')
  async addUser(user: User) {
    return await this.userService.create(user);
  }
}

总结与预告

在这两节,我们花了相当长的篇幅对装饰器相关的概念与实际应用进行了一次彻底介绍。从装饰器语法到不同类型装饰器的使用,再到反射、反射元数据,最后到控制反转与依赖注入,以及简单的 IoC 路由与 IoC 容器实现。这些概念可以帮助你在使用基于装饰器的工具库时,更加熟悉其底层的原理。同时,如果你想自己开发一些基于装饰器的工具库,这一节的内容也是一个不错的开始。

在接下来两节,我们将投入另一个方面的实战:TSConfig 配置解析。如果你也曾对着一堆配置较劲半天,却没看出个所以然的经历,这一次可以放心了。我们将在下面两节全面解析大部分配置,包括每一条配置的作用、表现以及与它关联的配置们。

扩展阅读

类型严格的装饰器

在这一节的代码中,我们并没有特别关注类型的严格性。实际上装饰器的类型定义也是如此:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

这些类型定义使用的是非常宽泛的类型, 并没有进行对应的约束。而如果将这些类型进行约束,实际上我们就可以实现一个类型严格的装饰器。如我们希望装饰器 @OnlyFoo 只能在 Foo 及其子类上应用,此时就可以通过约束 target 的类型实现:

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

type RestrictedClassDecorator<TClass extends object> = (
  target: ClassStruct<TClass>
) => ClassStruct<TClass> | void;

function OnlyFoo(): RestrictedClassDecorator<Foo> {
  return (target: ClassStruct<Foo>) => {};
}

function OnlyBar(): RestrictedClassDecorator<Bar> {
  return (target: ClassStruct<Bar>) => {};
}

来实际使用一下:

@OnlyFoo()
// 装饰器函数返回类型“void | ClassStruct<Bar>”不可分配到类型“void | typeof Foo”
@OnlyBar()
class Foo {
  foo!: string;
}

@OnlyFoo()
class DerivedFoo extends Foo {
  foo!: string;
}

// 装饰器函数返回类型“void | ClassStruct<Foo>”不可分配到类型“void | typeof Bar”。
@OnlyFoo()
@OnlyBar()
class Bar {
  bar!: string;
}

类似的,我们还可以实现约束方法装饰器只能在同步或异步函数上调用:

type AsyncFunc = (...args: any[]) => Promise<any>;

type OnlyAsyncMethodDecorator = (
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<AsyncFunc>
) => void;

function OnlyAsyncFunc(): OnlyAsyncMethodDecorator {
  return (target, propKey, descriptor) => {};
}

class Foo {
  // 类型“TypedPropertyDescriptor<() => void>”的参数不能赋给类型“TypedPropertyDescriptor<AsyncFunc>”的参数。
  @OnlyAsyncFunc()
  handler() {}

  @OnlyAsyncFunc()
  async asyncHandler() {}
}

以及属性装饰器只用应用在特定类型的属性上:

type LiteralPropertyDecorator = (
  target: Object,
  propertyKey: 'linbudu'
) => void;

function OnlyLiteralProperty(): LiteralPropertyDecorator {
  return (target, propertyKey) => {};
}

type PickByValueType<T, Value> = {
  [Key in keyof T]: T[Key] extends Value ? Key : never;
}[keyof T];

type StringTypePropertyDecorator = <T extends object>(
  target: T,
  propertyKey: PickByValueType<T, string>
) => void;

function OnlyStringTypeProperty(): StringTypePropertyDecorator {
  return (target, propertyKey) => {};
}

class Foo {
  @OnlyStringTypeProperty()
  str!: string;

  // 类型“"bool"”的参数不能赋给类型“PickByValueType<Foo, string>”的参数。
  @OnlyStringTypeProperty()
  bool: boolean = true;

  @OnlyLiteralProperty()
  linbudu!: 'linbudu';
}

这里比较巧妙的是,由于我们只能获取到被装饰的属性名,无法直接获取到其类型,因此通过此前我们学习过的 PickByValueType 工具类型,将这个类上所有符合类型的属性名都提取了出来(作为字面量类型),然后使用这一字面量类型作为类型约束。

上一节学习了装饰器与反射元数据的基本使用后,这一节我们将在其基础上来了解控制反转依赖注入等概念,我们会使用装饰器配合反射元数据实现这一设计模式,以及实现基于装饰器的路由体系与一个简单的控制反转容器。

本节代码见:Decorators

控制反转与依赖注入

控制反转即 Inversion of Control,它是面向对象编程中的一种设计模式,可以用来很好地解耦代码。

由于控制反转出现的时间较晚,因而没有被包括在四人组的设计模式一书当中,但它仍然是一种设计模式。

假设我们存在多个具有依赖关系的类,可能会想当然这么写:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}

现在一共只有三个类,倒还没问题,如果随着开发这些类的数量与依赖关系复杂度暴涨,C 依赖 A B,D 依赖 A C,F 依赖 B C D…,再加上每个类需要实例化的参数可能又有所不同,此时再去手动维护这些依赖关系与实例化过程就是灾难了。

而控制反转模式则能够很好地解决这一问题,它引入了一个容器的概念,内部自动地维护了这些类的依赖关系,当我们需要一个类的时候,它会帮我们把这个类内部依赖的实例都填充好,我们直接用就行:

class F {
  constructor() {
    this.d = Container.get(D);
  }
}

此时,我们的实例 D 已经完成了对 A、C 的依赖填充,C 也完成了 A、B 的依赖填充,也就是说所有复杂的依赖关系都被处理完毕了。

这一模式就叫做控制反转。我们此前手动维护关系的模式则成为控制正转。举个例子,当我们想要处对象时,会上 Soul 这样的交友平台一个一个找,择偶条件是由我自己决定的,这就叫控制正转。现在我觉得这样太麻烦了,直接把自己的介绍、择偶条件上传到世纪佳缘,如果有人认为我不错,就会主动向我发起聊天,而这就是控制反转

控制反转的实现方式主要有两种,依赖查找依赖注入。它们的本质其实均是将依赖关系的维护与创建独立出来

其中依赖查找在 JavaScript 中并不多见,它其实就是将实例化的过程放到了另外一个新的 Factory 方法中:

class Factory {
  static produce(key: string) {
    // ...
  }
}

class F {
  constructor() {
    this.d = Factory.produce("D");
  }
}

在这里,我们的 Factory 类会按照传入的 key 去查找目标对象,然后再进行实例化与赋值过程。而依赖注入的代码则是这样的:

@Provide()
class F {
  @Inject()
  d: D;
}

可以看到这里我们不需要手动进行赋值,只需要声明这个属性,然后使用装饰器标明它需要被注入一个值即可。

这里的 Provide 即标明这个类需要被注册到容器中,如果别的地方需要这个类 F 时,其内部的 d 属性需要被注入一个 D 的实例,而 D 的实例又需要 A、C 的实例等等。这一系列的过程是完全交给容器的,我们需要做的就只是用装饰器简单标明下依赖关系即可。

很明显,相比于依赖查找,依赖注入使用起来更加简洁,几乎不需要额外的业务代码,即不需要一个额外的 Factory 方法去维护实例化逻辑,但其依赖逻辑要更加黑盒。

而装饰器如何实现依赖注入,我想其实你也能 get 到,不就是我们上面所说的元数据吗?比如在属性中通过 Inject 装饰器注册一份元数据,告诉容器这个类的哪些属性需要被注入,然后容器会在内部存储的类里面对应地进行查找。

在部分前端框架中同样大量使用了基于装饰器的依赖注入体系,如 Angular、Nest、MidwayJS 等,目前来看在 NodeJs 框架中的使用要更为常见。如 Nest 与 Midway 中基于装饰器实现了路由、生命周期、模块、中间件与拦截器等等功能,举例来说,基于装饰器的路由可能是这么写的:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {}

  @Post('/add')
  async addUser() {}
}

这么个路由声明意味着,GET /user/list 时会调用 userList 方法,而 POST /user/add 时则会调用 addUser 方法。

学习了依赖注入之后,其实我们也可以来自己实现一个装饰器路由体系!

基于依赖注入的路由实现

本节的代码是我最初在深入浅出 TypeScript 一书中学习到的内容,个人认为非常适合用于加深对依赖注入的理解,因此在其基础上进一步完善后,作为本节的实例代码。

我们的最终目的就是实现上面基于装饰器的路由能力,以及启动一个 Node Server 来完成对这个路由的承接。

分析一下我们需要哪些能力?最重要的就是把每个方法对应的请求路径、请求方法和具体实现绑定起来,也就是在 GET /user/list 时,我们需要调用 userList 方法,并将返回值作为响应。那么,在方法的装饰器 GET POST 上,我们就可以将请求方法、请求路径、方法名、方法实现等信息注册为元数据,然后通过一个统一的提取手段来将它们组装起来。

export enum METADATA_KEY {
  METHOD = 'ioc:method',
  PATH = 'ioc:path',
  MIDDLEWARE = 'ioc:middleware',
}

export enum REQUEST_METHOD {
  GET = 'ioc:get',
  POST = 'ioc:post',
}

export const methodDecoratorFactory = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      // 在方法实现上注册 ioc:method - 请求方法 的元数据
      Reflect.defineMetadata(METADATA_KEY.METHOD, method, descriptor.value!);
      // 在方法实现上注册 ioc:path - 请求路径 的元数据
      Reflect.defineMetadata(METADATA_KEY.PATH, path, descriptor.value!);
    };
  };
};

export const Get = methodDecoratorFactory(REQUEST_METHOD.GET);
export const Post = methodDecoratorFactory(REQUEST_METHOD.POST);

这样一来,@Get("/list") 其实就是注册了 ioc:method - ioc:getioc:path - "list" 这样的两对元数据,分别标识了请求方法与请求路径。需要注意的是,我们是在方法体上去注册的,这样在最终处理时,可以通过这个类的原型拿到方法体,继而获得注册的元数据。

Controller 中就简单一些了,我们只需要拿到它的请求路径信息,然后拼接在这个类中所有请求方法的请求路径前即可:

export const Controller = (path?: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(METADATA_KEY.PATH, path ?? '', target);
  };
};

在最后信息组装时,我们需要做这么几步:

  • 获取根路径,即 Controller 装饰器的入参
  • 获取这个类实例的原型对象
  • 在原型对象上基于方法名获得方法体,继而拿到定义的请求路径、请求方法、请求实现

来看实际代码:

type AsyncFunc = (...args: any[]) => Promise<any>;

interface ICollected {
  path: string;
  requestMethod: string;
  requestHandler: AsyncFunc;
}

export const routerFactory = <T extends object>(ins: T): ICollected[] => {
  const prototype = Reflect.getPrototypeOf(ins) as any;

  const rootPath = <string>(
    Reflect.getMetadata(METADATA_KEY.PATH, prototype.constructor)
  );

  const methods = <string[]>(
    Reflect.ownKeys(prototype).filter((item) => item !== 'constructor')
  );

  const collected = methods.map((m) => {
    const requestHandler = prototype[m];
    const path = <string>Reflect.getMetadata(METADATA_KEY.PATH, requestHandler);

    const requestMethod = <string>(
      Reflect.getMetadata(METADATA_KEY.METHOD, requestHandler).replace(
        'ioc:',
        ''
      )
    );

    return {
      path: `${rootPath}${path}`,
      requestMethod,
      requestHandler,
    };
  });
  return collected;
};

对于开始我们给出的路由使用方法,收集到的最终信息是这样的:

[
  {
    path: '/user/list',
    requestMethod: 'get',
    requestHandler: [AsyncFunction: userList]
  },
  {
    path: '/user/add',
    requestMethod: 'post',
    requestHandler: [AsyncFunction: addUser]
  }
]

现在我们就要来使用一个真正的 Node 服务来检验一下了,直接使用内置的 HTTP 模块启动一个服务器:

import http from 'http';

http
  .createServer((req, res) => {})
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
  });

接下来我们需要做的,就是在 createServer 内去依据请求路径与请求方法调用对应的实现了。我们会遍历收集到的信息,查看是否有某一个对象的路径与请求方法都匹配上了,如果有,就调用这个方法返回:

http
  .createServer((req, res) => {
    for (const info of collected) {
      if (
        req.url === info.path &&
        req.method === info.requestMethod.toLocaleUpperCase()
      ) {
        info.requestHandler().then((data) => {
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify(data));
        });
      }
    }
  })
  .listen(3000)
  .on('listening', () => {
    console.log('Server ready at http://localhost:3000 \n');
    console.log('GET /user/list at http://localhost:3000/user/list \n');
    console.log('POST /user/add at http://localhost:3000/user/add \n');
  });

在 Controller 中新增简单的方法返回:

@Controller('/user')
class UserController {
  @Get('/list')
  async userList() {
    return {
      success: true,
      code: 10000,
      data: [
        {
          name: 'linbudu',
          age: 18,
        },
        {
          name: '林不渡',
          age: 28,
        },
      ],
    };
  }

  @Post('/add')
  async addUser() {
    return {
      success: true,
      code: 10000,
    };
  }
}

访问 http://localhost:3000/user/list 来试一下:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

成功了!是不是还有点小激动?你还可以试着加上同样基于装饰器的中间件、拦截器等机制,思路仍然是一致的:注册提取组装以及匹配调用

实际上,在 Nest 这一类框架中,通常会通过完整的容器机制来进行元数据的注册与提取,如 routerFactory(new UserController()) 这一过程,其实就是在你从容器中取出这个类时就已经自动完成了的。那么,我们要如何实现一个如此贴心的容器?

实现一个简易 IoC 容器

实现一个简单的 IoC 容器可以很好地帮助我们总结装饰器、依赖注入、元数据的相关知识,以及理解“控制反转”的本质。

关于这个容器,我们最终想实现的使用方式是这样的:

@Provide()
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get(Car);

car.run(); // 驱动已生效于 Car !

先来梳理一下思路,要实现这么个效果,首先我们需要一个容器,即控制反转中提到的独立的控制方,我们的 Car 依赖于驱动 Driver,这个容器会帮我们完成 Driver 注入到 Car 内的操作。那这个容器如何知道有哪些类需要被提前实例化呢?我们使用一个 Provide 装饰器,被其标记的 Class 会自动被容器收集。然后在需要使用这些类实例的地方,使用 Inject 装饰器声明这里需要哪个实例,容器就会自动地将这个属性注入进来。

这里有一个比较复杂的地方,在存储一个类和注入一个类时,我们需要有一个标识符,才能实现一一对应的注入方式。在上面的例子里我们的 Provide 和 Inject 装饰器都是使用无参数调用的,这样的话标识符从何而来?你可能会想到使用内置的元数据信息!的确是这样,但是为了降低学习成本,我们先来了解如何不使用元数据来实现这个 IoC 容器,也就是我们能够这么使用:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide('Car')
class Car {
  @Inject('DriverService')
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

const car = Container.get<Car>('Car')!;

car.run();

这样的话就就简单多了,我们只需要基于字符串来存储、查找、注入一个类就好了。

首先我们创建一个容器,很明显,它需要一个 Map 来以字符串-类的方式存储这些信息,以及 get 与 set 方法:

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

class Container {
  private static services: Map<string, ClassStruct> = new Map();
  
  public static set(key: string, value: ClassStruct): void {}

  public static get<T = any>(key: string): T | undefined {}

  private constructor() {}
}

我们使用私有构造函数来避免这个类被错误地实例化,毕竟它其实只是用来将这些逻辑收拢到一起。

然后就像我们前面说的,Provide 和 Inject 装饰器需要进行存储与注入工作:

function Provide(key: string): ClassDecorator {
  return (Target) => {
    Container.set(key, Target as unknown as ClassStruct);
  };
}

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
   
  };
}

Provide 倒简单,但 Inject 就有些麻烦了,我们在前面提到属性装饰器是无法对类的属性进行操作的,因此我们这里只能使用委托的方式。也就是说,我们先告诉容器有哪些属性需要进行注入,以及需要注入的类的标识符,等我们从容器中去取这个类的时候,容器会帮我们处理这些。

因此容器中需要再增加一个 Map,它的键与键值均为字符串类型:

class Container {
  public static propertyRegistry: Map<string, string> = new Map();
  
}

这样在 Inject 中,我们需要做的就是注册信息:

function Inject(key: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key
    );
  };
}

需要注意的是,这里我们注册的是 Car:driver – DriverService 的形式,以此来同时保存这个属性所在的类名称。

接下来,我们需要做的就是 get 与 set 方法了。set 方法简单,直接注册 services 就好:

class Container {
  public static set(key: string, value: ClassStruct): void {
    Container.services.set(key, value);
  }
}

get 方法就要复杂一些了,它需要在我们取出一个类(Container.get('Car'))时,帮我们实例化这个类以及注入这个类内部声明的依赖(DriverService)。整理一下具体步骤:

  • 使用传入的标识符在容器内查找这个类是否已经注册,如果有则进行下一步,没有就返回 undefined。
  • 对于已注册的类,首先将其实例化,然后检查 propertyRegistry ,查看这个类内部是否声明了对外部的依赖?
  • 将这些外部依赖的类从容器中取出(同样通过 get 方法),然后实例化。
  • 将这些实例传递给对应的属性。

我们的大致实现如下:

class Container {
    public static get<T = any>(key: ServiceKey): T | undefined {
    // 检查是否注册
    const Cons = Container.services.get(key);

    if (!Cons) {
      return undefined;
    }

    // 实例化这个类
    const ins = new Cons();

    // 遍历注册信息
    for (const info of Container.propertyRegistry) {
      // 注入标识符与要注入类的标识符
      const [injectKey, serviceKey] = info;
      // 拆分为 Class 名与属性名
      const [classKey, propKey] = injectKey.split(':');

      // 如果不是这个类,就跳过
      if (classKey !== Cons.name) continue;

      // 取出需要注入的类,这里拿到的是已经实例化的
      const target = Container.get(serviceKey);

      if (target) {
        // 赋值给对应的属性
        ins[propKey] = target;
      }
    }

    return ins;
  }
}

来试着调用,会发现已经成功了:

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

每次传入字符串的实现肯定不够优雅,我们在使用 Nest、Angular 等框架时,也并不会经常使用字符串作为标识符来实现依赖注入。

可是,如果不使用字符串,我们要用什么来作为标识符呢?聪明的你肯定想到了,可以使用内置的元数据来作为标识符,比如在这种情况下:

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  run() {
    this.driver.adapt('Car');
  }
}

对于 driver 属性,我们就可以使用它的类型标注 Driver 来作为标识符。那接下来我们来改写上面的容器实现。

基于内置元数据实现

其实最难的一部分我们已经解决了,即如何存储并对应地进行注入,现在要做的不过是升级优化一下,支持在不传入标识符时使用内置元数据作为标识符。首先对 Provide 和 Inject 做改造:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

function Inject(key?: string): PropertyDecorator {
  return (target, propertyKey) => {
    Container.propertyRegistry.set(
      `${target.constructor.name}:${String(propertyKey)}`,
      key ?? Reflect.getMetadata('design:type', target, propertyKey)
    );
  };
}

本节的代码并没有在类型上进行十分精确的处理,这主要是为了避免增加额外的代码复杂度,毕竟我们的主要目的是理解依赖注入而不是类型。

在 Inject 中,我们支持了在不传入标识符时,使用 Reflect.getMetadata('design:type', target, propertyKey) 作为默认的标识符,这里的元数据是一个完整的类,即 Class Driver 。

对应的,为了支持使用 Class 作为标识符进行查找,在 Provide 装饰器中我们需要确保也使用 Class 作为标识符来存储一份:

function Provide(key?: string): ClassDecorator {
  return (Target) => {
    Container.set(key ?? Target.name, Target as unknown as ClassStruct);
    // 不论是否传入 key,都使用 Class 作为 key 注册一份
    Container.set(Target, Target as unknown as ClassStruct);
  };
}

然后就没了!我们并不需要修改 Container 的逻辑,只需要调整类型即可:

type ServiceKey<T = any> = string | ClassStruct<T> | Function;

class Container {
  private static services: Map<ServiceKey, ClassStruct> = new Map();

  public static propertyRegistry: Map<string, string> = new Map();

  public static set(key: ServiceKey, value: ClassStruct): void {}

  public static get<T = any>(key: ServiceKey): T | undefined {}
  private constructor() {}
}

现在我们可以同时使用 @Inject() 与 @Inject('DriverService') 这两种方式来实现注入了,来最后测试一下:

@Provide('DriverService')
class Driver {
  adapt(consumer: string) {
    console.log(`\n === 驱动已生效于 ${consumer}!===\n`);
  }
}

@Provide()
class Fuel {
  fill(consumer: string) {
    console.log(`\n === 燃料已填充完毕 ${consumer}!===`);
  }
}

@Provide()
class Car {
  @Inject()
  driver!: Driver;

  @Inject()
  fule!: Fuel;

  run() {
    this.fule.fill('Car');
    this.driver.adapt('Car');
  }
}

@Provide()
class Bus {
  @Inject('DriverService')
  driver!: Driver;

  @Inject('Fuel')
  fule!: Fuel;

  run() {
    this.fule.fill('Bus');
    this.driver.adapt('Bus');
  }
}

const car = Container.get(Car)!;
const bus = Container.get(Bus)!;

car.run();
bus.run();

TypeScript - 控制反转与依赖注入:基于装饰器的依赖注入实现

学习完这一节后,请你试着把上一部分的装饰器路由体系也基于这个简单的容器重新实现与改善,如新增对 Service 层与中间件层的注入:

// 如何设计入参?
function logMiddleware() {
    // 中间件逻辑在何时执行?
}

@Controller('/user')
class UserController {
  constructor(@Inject() private userService: UserService) {}
  
  @Middleware(logMiddleware)
  @Get('/list')
  async userList() {
    return await this.userService.all();
  }

  @Post('/add')
  async addUser(user: User) {
    return await this.userService.create(user);
  }
}

总结与预告

在这两节,我们花了相当长的篇幅对装饰器相关的概念与实际应用进行了一次彻底介绍。从装饰器语法到不同类型装饰器的使用,再到反射、反射元数据,最后到控制反转与依赖注入,以及简单的 IoC 路由与 IoC 容器实现。这些概念可以帮助你在使用基于装饰器的工具库时,更加熟悉其底层的原理。同时,如果你想自己开发一些基于装饰器的工具库,这一节的内容也是一个不错的开始。

在接下来两节,我们将投入另一个方面的实战:TSConfig 配置解析。如果你也曾对着一堆配置较劲半天,却没看出个所以然的经历,这一次可以放心了。我们将在下面两节全面解析大部分配置,包括每一条配置的作用、表现以及与它关联的配置们。

扩展阅读

类型严格的装饰器

在这一节的代码中,我们并没有特别关注类型的严格性。实际上装饰器的类型定义也是如此:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

这些类型定义使用的是非常宽泛的类型, 并没有进行对应的约束。而如果将这些类型进行约束,实际上我们就可以实现一个类型严格的装饰器。如我们希望装饰器 @OnlyFoo 只能在 Foo 及其子类上应用,此时就可以通过约束 target 的类型实现:

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

type RestrictedClassDecorator<TClass extends object> = (
  target: ClassStruct<TClass>
) => ClassStruct<TClass> | void;

function OnlyFoo(): RestrictedClassDecorator<Foo> {
  return (target: ClassStruct<Foo>) => {};
}

function OnlyBar(): RestrictedClassDecorator<Bar> {
  return (target: ClassStruct<Bar>) => {};
}

来实际使用一下:

@OnlyFoo()
// 装饰器函数返回类型“void | ClassStruct<Bar>”不可分配到类型“void | typeof Foo”
@OnlyBar()
class Foo {
  foo!: string;
}

@OnlyFoo()
class DerivedFoo extends Foo {
  foo!: string;
}

// 装饰器函数返回类型“void | ClassStruct<Foo>”不可分配到类型“void | typeof Bar”。
@OnlyFoo()
@OnlyBar()
class Bar {
  bar!: string;
}

类似的,我们还可以实现约束方法装饰器只能在同步或异步函数上调用:

type AsyncFunc = (...args: any[]) => Promise<any>;

type OnlyAsyncMethodDecorator = (
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<AsyncFunc>
) => void;

function OnlyAsyncFunc(): OnlyAsyncMethodDecorator {
  return (target, propKey, descriptor) => {};
}

class Foo {
  // 类型“TypedPropertyDescriptor<() => void>”的参数不能赋给类型“TypedPropertyDescriptor<AsyncFunc>”的参数。
  @OnlyAsyncFunc()
  handler() {}

  @OnlyAsyncFunc()
  async asyncHandler() {}
}

以及属性装饰器只用应用在特定类型的属性上:

type LiteralPropertyDecorator = (
  target: Object,
  propertyKey: 'linbudu'
) => void;

function OnlyLiteralProperty(): LiteralPropertyDecorator {
  return (target, propertyKey) => {};
}

type PickByValueType<T, Value> = {
  [Key in keyof T]: T[Key] extends Value ? Key : never;
}[keyof T];

type StringTypePropertyDecorator = <T extends object>(
  target: T,
  propertyKey: PickByValueType<T, string>
) => void;

function OnlyStringTypeProperty(): StringTypePropertyDecorator {
  return (target, propertyKey) => {};
}

class Foo {
  @OnlyStringTypeProperty()
  str!: string;

  // 类型“"bool"”的参数不能赋给类型“PickByValueType<Foo, string>”的参数。
  @OnlyStringTypeProperty()
  bool: boolean = true;

  @OnlyLiteralProperty()
  linbudu!: 'linbudu';
}

这里比较巧妙的是,由于我们只能获取到被装饰的属性名,无法直接获取到其类型,因此通过此前我们学习过的 PickByValueType 工具类型,将这个类上所有符合类型的属性名都提取了出来(作为字面量类型),然后使用这一字面量类型作为类型约束。

 

免责声明:
1.本站所有内容由本站原创、网络转载、消息撰写、网友投稿等几部分组成。
2.本站原创文字内容若未经特别声明,则遵循协议CC3.0共享协议,转载请务必注明原文链接。
3.本站部分来源于网络转载的文章信息是出于传递更多信息之目的,不意味着赞同其观点。
4.本站所有源码与软件均为原作者提供,仅供学习和研究使用。
5.如您对本网站的相关版权有任何异议,或者认为侵犯了您的合法权益,请及时通知我们处理。
火焰兔 » TypeScript – 控制反转与依赖注入:基于装饰器的依赖注入实现