什么是IoC

IoC,全称 Inversion of Control,即 控制反转,是一种设计原则和编程技巧。控制反转的主要目的是将组件之间的依赖关系从程序内部转移到外部容器或框架中。通过使用IoC容器,开发者可以将组件的创建、配置和管理交给容器来完成。这样一来,组件之间的依赖关系就不再是硬编码在组件内部的,而是通过配置文件或注解来声明和解析的。这使得组件更加模块化,易于测试和维护。

依赖注入(Dependency Injection,简称DI):这是IoC的一种主要实现方式。依赖注入是将组件所需的依赖项(如服务、对象等)通过外部容器注入到组件中的过程。这种方式可以降低组件之间的耦合度,提高代码的可测试性和可维护性。

如果没了解过,可以看下面代码示例:

正常如果我们泪中某一个属性依赖另一个类,需要显式地实例化一个对象

class Service {
  method() {
    console.log("service method")
  }
}

class Controller {
  service = new Service();

  run() {
    this.service.method()
  }
}

const controller = new Controller()
controller.run()

但是如果使用依赖注入后我们的代码可能变成下面的样子(并不是最终的代码形式)

class Service {
  method() {
    console.log("service method")
  }
}

class Controller {
  service: Service;

  run() {
    this.service.method()
  }
}

const controller = Container.get(Controller)
controller.run()

可以看到,我们不再需要显示的实例化对象,只需要通过容器的方法就可以获得实例。但是一般来说,我们需要借助装饰器的一些能力来完成依赖注入,从而达到上面的效果。关于装饰器的用法可以看这篇文章:《TypeScript 中的装饰器》

实现一个IoC容器

由于TypeScript5.X装饰器发生变化,并且不想在兼容,所以本文内容仅适用于旧版装饰器语法。

上面的代码提到了IoC 容器,下面我们来实现一个IoC容器,作用是:存储所有需要自动创建的类,在获取类的时候自动注入相关依赖。

type ConstructorType = new (...args: any[]) => any;
type ServiceKey = string | ConstructorType;

export class IocContainer {
  // IOC类映射
  static servicesMap: Map<ServiceKey, ConstructorType> = new Map();
  // 实例属性映射
  static propertyKeysMap: Map<string, ConstructorType> = new Map();
  
  // 获取实例
  static get() {}
  
  // 存储实例到容器
  static set() {}
}

我们使用Map结构来存储类,servicesMap的key是字符串或者类的构造函数,作用是就是存储类;propertyKeysMap的key是类名和属性拼接的字符串,用来存储需要给类注入的属性依赖的类(后面会用到)。

set方法很简单,只需要把类保存到servicesMap即可。

class IocContainer {
  
  static set(key: ServiceKey, value: ConstructorType) {
    IocContainer.servicesMap.set(key, value);
  }
}

get方法稍微复杂一些,需要在获取实例的时候找到相关的属性依赖进行注入。

class IocContainer {
  
  static get(key: ServiceKey) {
    const Constructor = IocContainer.servicesMap.get(key);
    if (!Constructor) return undefined;
    
    const instance = new Constructor();
    const ConstructorName = Constructor.name;
    // 遍历所有保存的属性依赖,找到当前类的需要的属性
    IocContainer.propertyKeysMap.forEach((val, key) => {
      const [className, property] = key.split(':');
      if (className === ConstructorName) {
        (instance as any)[property] = IocContainer.get(val);
      }
    })
    return instance;
  }
}

上面的代码只涉及到了需要被注入类的保存,并没有保存属性对应的依赖,这部分将在后面通过装饰器的能力实现。

装饰器应用

上面我们实现了IoC容器,下面开始我们使用装饰器补齐剩下的能力。

Privode

@Provide是一个类装饰器,用于声明类可以被自动注入,也就是将类保存到IoC容器的servicesMap中。

export function Provide(): ClassDecorator {
  return function (target: any) {
    IocContainer.set(target, target);
  }
}

Inject

@Inject是一个属性装饰器,用于在类中声明属性需要被注入的依赖,将属性标识存到IoC容器中的propertyKeysMap中。

export function Inject(): PropertyDecorator {
  return function (target: any, property: any) {
    const key = `${target.constructor.name}:${property}`;
    const value = Reflect.getMetadata('design:type', target, property);
    IocContainer.propertyKeysMap.set(key, value);
  }
}

这里用到了reflect-metadata的能力,获取装饰器修饰属性的类型,这个类型应该是个被Provide装饰器修饰的类,将这个类保存到map中,key是构造函数和属性拼接的字符串,例如Constructor:property

Controller

@Controller作用其实和@Provide相似,只是在Provide的基础上增加了记录路由的功能

export function Controller(path: string = ""): ClassDecorator {
  return function (target: any) {
    Reflect.defineMetadata('custom:path', path, target);
    IocContainer.set(target, target);
  }
}

通过reflect-metadata的API将路径信息存储到类的元数据中。

各种 methods

@Controller搭配使用,方法装饰器使用在方法上,用于区分路由及方法,包括@Get@Post等。

由于各种请求方法装饰器的功能类似,区别仅仅是请求方法的不同,所以我们可以写一个通用的方法。

function CommonMethod(method: string, path?: string): MethodDecorator {
  return function (target: any, property: any, descriptor: any) {
    const rawMethod = descriptor.value;
    Reflect.defineMetadata('custom:method', method, rawMethod);
    Reflect.defineMetadata('custom:path', path ?? "", rawMethod);
  }
}

通过方法装饰器获取被装饰的方法函数,将请求方法和请求路径保存到方法的元数据中。

然后通过克里化函数进行下处理接即可

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function (...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  };
}

export const Get = curry(CommonMethod)('GET');

export const Post = curry(CommonMethod)('POST');

然后可以测试下我们的代码

@Controller("/test")
export class TestController {
  @Inject()
  testService: TestService;

  @Get("getName")
  async get() {
    const name = await this.testService.getName();
    return {
      data: {
        name
      },
      code: 200,
      success: true,
    }
  }
}

@Provide()
export class TestService {
  async getName() {
    return "test";
  }
}

image-20230918233551558

参数装饰器

可实现的参数装饰器有很多,ParamQueryHeader……

这里取Query为例,通过参数装饰器获取目标方法的参数位置,根据参数下标进行设置标识

export function Query(key: string): ParameterDecorator {
  return function (target: any, property: any, parameterIndex: number) {
    const paramData = Reflect.getMetadata(KeyEnum.PARAMETER, target, property);
    if (!paramData) {
      const paramInfo = {
        type: 'query',
        key,
        index: parameterIndex
      }
      const paramList = []
      paramList[parameterIndex] = paramInfo
      Reflect.defineMetadata(KeyEnum.PARAMETER, paramList, target, property);      
    } else {
      paramData[parameterIndex] = {
        key,
        type: 'query',
        index: parameterIndex
      }
      Reflect.defineMetadata('ioc:param', paramData, target, property);
    }
  }
}

解析器

有了容器和装饰器之后,我们还需要解析器来做最后的拼装,因为最后的路由功能还是要靠HTTP服务来承载,所以我们需要将Controller和Method上的路径进行拼接处理。

思路如下:

  1. 通过IocContainer获取实例;
  2. 获取实例的的原型,目的是获取类中声明的方法;
  3. 获取存储在metadata中的路径信息和方法信息;
  4. 返回拼装后的路由信息

获取类中的方法时可以使用Object.getOwnPropertyNames(Object.getPrototypeOf(ins)),获取实例原型的属性,但是这样会多出一个constructor属性,需要手动过滤

image-20230918233318373

或者使用反射Reflect.ownKeys(Reflect.getPrototypeOf(ins)),效果是一样的。

实现代码如下:

export function routeParser(instance: any) {
  const instanceProto = Reflect.getPrototypeOf(instance) as any;
  const rootPath = (Reflect.getMetadata(KEY.PATH, instanceProto.constructor)) as string;
  
  const methodNameList = (Reflect.ownKeys(instanceProto).filter((name) => name !== "constructor"));
  const info = methodNameList.map((name) => {
    const rawMethod = instanceProto[name];
    const reqMethod = Reflect.getMetadata('ioc:method', rawMethod);
    const reqPath = Reflect.getMetadata('ioc:path', rawMethod);
    return {
      url: `${rootPath}/${reqPath}`,
      reqMethod,
      rawMethod: rawMethod.bind(instance),
    };
  })
  
  return info;
}

const testRoute = IocContainer.get(TestController)
const routeInfo = parseApiInfo(testRoute);

image-20230918234023084

这里我们手动导入的Controller进行的解析,这如果在实际环境中使用是非常蠢的,需要使用node API自动导入目录下文件进行解析。

有了路由信息之后就可以使用http服务器进行处理了,express或者koa等都可以,这里使用最基础的http模块进行测试。

http.createServer((req, res) => {
  routeInfo.forEach(async info => {
    const { url, reqMethod, rawMethod, params } = info;
    if (req.url.startsWith(url) && reqMethod === req.method) {
      const args = params.map(param => {
        if (param.type === 'query') {
          const query = new URLSearchParams(parse(req.url).query)
          return query.get(param.key)
        }
      });
      res.end(JSON.stringify(await rawMethod(...args)))
    }
  })
}).listen(3001);

访问路径得到如下效果(记得在Controller中补充get方法的参数@Query('id') id: string

image-20230919221645291

一个丐版的IoC框架就实现了


前端小白