我用200 行TS代码实现了一个IOC框架(附源码)

·  阅读 2260

IOC介绍

IOC,全称 Inversion of Control( 控制反转 ,是面向对象编程中的一个常用设计模式,将软件开发者从繁杂的对象操作(初始化/调用/销毁)工作中解放出来,故名控制反转,软件开发者不再主动操作着各个对象。IOC在Web服务端场景用着广泛的应用,最出名的莫过于Java 的Spring全家桶,在大前端领域,也能看到像NestJS这样的优秀服务端框架大规模的使用了IOC设计模式。

在早几年,前端几乎很少谈论IOC这个东西,首先,在基于事件系统的UI编程范式中,人们更多的是在讨论MVP或者是MVVM的开发模式,由于服务治理不是当前UI编程最痛的痛点,所以就单纯的讨论IOC意义不是很大。直到大前端的出现,JS可以用Node写服务端,可以用Electron写桌面客户端,React Native 写移动端。服务端场景的延伸,使得IOC在前端领域也有了一定的谈资。除了这一点,得益于ECMAScript的快速发展,在ES7中引入了Decorator的元编程语法(meta-programming syntax),使得IOC模式的实现更加的优雅好用。上面提到的NestJS的IOC系统就是基于TS的Decorator语法实现的。

本篇文章一共包含三部分内容,第一部分会构造一个实际场景来解释为什么要使用IOC模式,它解决了什么样的问题,什么场景适合使用IOC模式。第二部分会从实现角度出发一步一步思考,阐述分析IOC模式在TS下实现思路,在大致梳理好了实现思路后,第三部分就是具体的代码实现,最后还会贴上一个已经实现好的仓库地址,感兴趣的同学可以将对应的代码克隆到本地体验。

Why

为什么要使用IOC?我们来构造一个场景来说明这个问题。

假设我们需要实现一个商城后台,需要提供商品管理/订单管理/物流管理/用户管理等基本功能,除此之外,我们还需要一些常用的基本能力,如日志模块,监控模块等。于是乎,我们基于模块化编程,分而治之的思想将我们的后台大致分为以下几个模块。 image.png

如果生活都像上面这张图一样简单清晰,那该多好。

模块大致划分好后,我们会发现一个问题,即使我们根据职责将模块划分好,各个模块间还是会存在相互依赖的场景,就像所有的服务都需要打印日志,所以,它们都会依赖日志服务;所有的业务服务,如商品服务,订单服务都需要做对应的监控,所以,它们都会依赖监控服务;除了对基础服务的依赖之外,各个业务服务之间还会存在依赖,如订单服务依赖商品服务获取对应订单的商品详情,依赖用户服务获取当前订单的购买者信息,物流服务依赖用户服务和订单服务获取当前登录用户的所有订单物流。所以我们的后台系统就设计成了这样,每条线条都表示着一种依赖关系。

image.png

原来,这才是真正的生活!

我们发现,当引入每个服务之间的依赖关系时,事情就开始变得有趣了起来。如果需要手动管理这些依赖关系,对于软件开发者来说,这无疑是一个噩梦,何况,上面这种情况还是我们构造出的简单场景,在实际业务开发中,情况也要复杂得多。

在引入IOC之前,我们尝试写下手动实现上述管理后台的伪代码,可以看到,每一行代码的执行顺序都需要经过深思熟虑,写完初始化代码,脑细胞已死伤过半,发量岌岌可危矣。

class Application {
  constructor() {
    this.loggerService = new LoggerService();
    this.monitorService = new MonitorService(this.loggerService);
    this.userService = new UserService(this.monitorService, this.loggerService);
    this.commodityService = new CommodityService(this.monitorService, this.loggerService);
    this.orderService = new OrderService(this.commodityService, this.monitorService, this.loggerService);
    this.logisticsService = new LogisticsService(this.orderService, this.userService, this.loggerService, this.monitorService)
  }
}
复制代码

接着我们引入IOC模式,让生活和我们重归于好。虽然下图中的依赖关系仍然存在,但是已经变成了虚线,表示软件开发者不再需要主动管理这些依赖关系,看起来就像第一张图那样美好。

image.png

于是乎,伪代码就变成了这样,整体代码结构更加符合直觉,由于不再需要关系相互的依赖关系,代码的可维护性得到了质的提升。

class Application {
  constructor() {
    this.iocSystem = new IOCSystem();
    this.iocSystem.init();
    this.loggerService = this.iocSystem.get('logger');
    this.monitorService = this.iocSystem.get('monitor');
    this.userService = this.iocSystem.get('user');
    this.commodityService = this.iocSystem.get('commodity');
    this.orderService = this.iocSystem.get('order');
    this.logisticsService = this.iocSystem.get('logistics');
  }
}
复制代码

实现思路

以上面的伪代码为最终目标,我们来梳理一下大致的实现思路。

服务注册

  1. 如果要实现IOC模式,首先要解决的是对象初始化的问题,既然要做初始化,就要获取对应服务的构造函数,我们可以使用一个注册表来保存每个服务的构造函数,同时,由于需要维护多个服务的构造函数,所以,每个服务还需要有一个唯一标识。那么这个注册表可以使用Map对象实现。

  2. 接着,我们考虑下每个服务的注册方式,可以使用主动注册的方式,在所以服务实现的地方,都主动将当前服务注册到一个注册表(Map)中,也可以使用decorator这种更加优雅直观的方式,在每个需要注册的服务类上,都使用一个decorator进行标记,然后在decorator实现中进行统一注册。后面的代码实现章节,我们会使用这种方式进行服务注册。

解决了服务注册的问题,我们现在拥有了一个完整的服务注册表,这个注册表中记录着当前应用的所有服务,每个服务都有一个唯一标识。仍然以上面商城后台为例,注册完所有服务的注册表应该长这样。为了方便,这里的唯一标识,我们使用一个简单的字符串表示,当然,如果是在生产环境,ES 规范中的Symbol对象可能会是更好的选择。

Services Register Table
loggerClass LoggerService
monitorClass MonitorService
orderClass OrderService
userClass UserService
logisticsClass LogisticsService
commodityClass CommodityService

依赖关系管理

  1. 就像上面说的那样,每个服务之间还会有依赖关系,如ServiceA 依赖 ServiceB,那么我们要做的就是,首先,ServiceB需要在ServiceA之前初始化好,其次,在初始化ServiceA的时候,我们需要将ServiceB注入到ServiceA中,所以这种依赖关系我们也得维护起来,那我们就再维护一个依赖关系表吧。

  2. 我们要如何管理这种依赖关系呢?答案就是parameter decorator,parameter decorator调用时的方法签名如下:

    function (Ctor: ServiceCtor, parameterKey: string, parameterIndex: number): void;
    复制代码

    其中,第一个参数是构造函数,第二个参数是构造函数参数的name,第三个参数是构造函数参数的位置信息。举个例子:

    class Person {
      constructor(
        @parameter // Person, name, 0,
        name,
        @parameter // Person, age, 1,
        age,
      ) {
        this.name = name;
        this.age = age
      }
    }
    
    function parameter(Ctor: ServiceCtor, parameterKey: string, parameterIndex: number) {
      console.log(Ctor, parameterKey, parameterIndex);
    }
    复制代码

通过parameter decorator,我们可以记录到某个模块对其他模块存在依赖,那具体对哪个模块存在依赖,我们还不清楚,我们可以把parameter decorator设计成一个高阶函数,结合服务注册表中的id,完成依赖信息的记录。

/**
 * id 就是服务注册时的唯一标识信息
 */
function inject(id: string) {
    return (Ctor: ServiceCtor, parameterKey: string, parameterIndex: number) => {
       // ...
    };
}

// 使用方式大致如下
@service('logger')
class LoggerService {
}

@service('monitor')
class MonitorService {
  constructor(
    @inject('logger')
    logger: LoggerService,
  ) {
  }
}
复制代码

完成这一步,我们可以得到一个类似下表的依赖关系表。

Dependency Table
Class LoggerService
Class MonitorService- Class LoggerService
Class UserService- Class LoggerService- Class MonitorService
......

服务初始化

完成上面两步,我们现在有了一个所有服务的注册表以及一个服务关系关联表,接着我们就要尝试实现上面IOCSystem.getService 方法用于初始化一个服务。

  • 对于某一个服务,我们能从服务注册表中获取其对应的构造函数,我们可以调用这个构造函数来初始化这个服务对象。
  • 如果这个服务有依赖,我们应该先初始化对应的依赖服务,并且按照顺序传入到对应的初始化函数中。
  • 依赖的服务仍然可能会有自己的依赖,所以我们可能需要用到递归算法来初始化对应的各个服务。
  • 多个服务可能会依赖同一个服务,我们希望每个服务都只初始化一次,所以在初始化的过程中,我们需要记录当前哪些服务已经被初始化了,如果服务已经被初始化了,则不用二次初始化。

上面这个就是大致的实现思路,其实,我们还可以再优化一下上面说到的递归,递归实现虽然简单,但是如果服务很多时,可能存在堆栈溢出的问题,我们可以使用有向图这个数据结构来优化它:

  1. 每个服务通过一个图中的一个节点来表示。

image.png

  1. 服务之间的依赖关系通过一条有向边来表示,如果服务A依赖服务B,那么对应的节点A有一条指向节点B的边。

image.png

  1. 在这个图中,所有的root节点(没有以这个节点为起始的有向边)表示节点没有依赖或者依赖的服务已经初始化好,满足初始化条件。上图中,只有Node B是根节点。

  2. 执行一个loop循环,每次loop中,判断图是否为空,如果图不为空,则找出所有的root节点并依次初始化,初始化完成后,移除对应的节点,并继续下一次loop,如果图为空,则表示所有的服务都已经初始化完成。如果图不为空,且找不到root节点,则表示服务间出现了循环引用,需要提示报错。

image.png

代码实现

  1. 实现服务注册decorator. 使用class decorator即可,我们能拿到对应的构造函数,并注册到一个全局的注册表中(Map)。

    // global service register table
    const serviceCtorStore = new Map<string, ServiceCtor>();
    
    export function service(id?: string) {
      return (Ctor: ServiceCtor) => {
        const serviceId = id || Ctor.name.slice(0, 1).toLowerCase().concat(Ctor.name.slice(1));
        if (serviceCtorStore.has(serviceId)) throw new Error(`service ${serviceId} already exist, do not register again`);
        // register service into table defined above.
        serviceCtorStore.set(serviceId, Ctor);
      };
    }
    复制代码
  2. 实现服务依赖注入decorator. 使用parameter decorator,使用relfect-metadata 来记录每个依赖关系,依赖关系包括服务ID以及对应的形参索引位置。

    export function inject(id: string) {
      return (Ctor: ServiceCtor, parameterKey: string, parameterIndex: number) => {
        if (Reflect.hasOwnMetadata(dependencyMetadataKey, Ctor)) {
          const dependencies = Reflect.getOwnMetadata(dependencyMetadataKey, Ctor);
          // record dependencies info,include id and index,the most important metadata.
          Reflect.defineMetadata(
            dependencyMetadataKey,
            [
              ...dependencies,
              {
                id,
                parameterKey,
                parameterIndex,
              },
            ],
            Ctor,
          );
        } else {
          Reflect.defineMetadata(
            dependencyMetadataKey,
            [
              {
                id,
                parameterKey,
                parameterIndex,
              },
            ],
            Ctor,
          );
        }
      };
    }
    复制代码
  3. 实现一个有向图数据结构。

    具体实现使用的是微软开源项目vscode中的实现,本文档的IOC具体实现实际也是受到vscode项目中的ioc实现的启发。其中,有向图数据结构的实现是完全照搬过来的。

  4. 节点Node的类型定义

    • data表示当前节点的数据
    • incoming是一个Map<String, Node>类型,map中的记录的每个节点Node',表示以当前节点Node为起始,Node'为终点的一条有向边。
    • outgoing 和incoming类似,只是有向边的方向正好和incoming相反,表示Node'到Node的一条有向边。
  5. 图Graph有如下几个属性和方法

    • _nodes 记录着图中的所有节点Node
    • roots() 返回这个图中的所有根节点(没有一条边是以这个节点为起始的节点)
    • insertEdge(from, to) 插入一条有向边,方向为from ---> to
    • removeNode(data) 移除一个节点Node
    • lookupOrInsertEdge(data) 插入一个节点
    • lookup(data) 获取一个节点
    • isEmpty() 当前图是否存在节点,不存在任何节点返回true,存在返回false
    /*---------------------------------------------------------------------------------------------
     *  Copyright (c) Microsoft Corporation. All rights reserved.
     *  Licensed under the MIT License. See License.txt in the project root for license information.
     *--------------------------------------------------------------------------------------------*/
    
    export class Node<T> {
      readonly data: T;
      readonly incoming = new Map<string, Node<T>>();
      readonly outgoing = new Map<string, Node<T>>();
    
      constructor(data: T) {
        this.data = data;
      }
    }
    
    export default class Graph<T> {
      private readonly _nodes = new Map<string, Node<T>>();
    
      constructor(private readonly _hashFn: (element: T) => string) {
        // empty
      }
    
      roots(): Node<T>[] {
        const ret: Node<T>[] = [];
        for (const node of this._nodes.values()) {
          if (node.outgoing.size === 0) {
            ret.push(node);
          }
        }
        return ret;
      }
    
      insertEdge(from: T, to: T): void {
        const fromNode = this.lookupOrInsertNode(from);
        const toNode = this.lookupOrInsertNode(to);
    
        fromNode.outgoing.set(this._hashFn(to), toNode);
        toNode.incoming.set(this._hashFn(from), fromNode);
      }
    
      removeNode(data: T): void {
        const key = this._hashFn(data);
        this._nodes.delete(key);
        for (const node of this._nodes.values()) {
          node.outgoing.delete(key);
          node.incoming.delete(key);
        }
      }
    
      lookupOrInsertNode(data: T): Node<T> {
        const key = this._hashFn(data);
        let node = this._nodes.get(key);
    
        if (!node) {
          node = new Node(data);
          this._nodes.set(key, node);
        }
    
        return node;
      }
    
      lookup(data: T): Node<T> | undefined {
        return this._nodes.get(this._hashFn(data));
      }
    
      isEmpty(): boolean {
        return this._nodes.size === 0;
      }
    
      toString(): string {
        const data: string[] = [];
        for (const [key, value] of this._nodes) {
          data.push(
            `${key}, (incoming)[${[...value.incoming.keys()].join(', ')}], (outgoing)[${[...value.outgoing.keys()].join(
              ',',
            )}]`,
          );
        }
        return data.join('\n');
      }
    
      /**
       * This is brute force and slow and **only** be used
       * to trouble shoot.
       */
      findCycleSlow() {
        for (const [id, node] of this._nodes) {
          const seen = new Set<string>([id]);
          const res = this._findCycle(node, seen);
          if (res) {
            return res;
          }
        }
        return undefined;
      }
    
      private _findCycle(node: Node<T>, seen: Set<string>): string | undefined {
        for (const [id, outgoing] of node.outgoing) {
          if (seen.has(id)) {
            return [...seen, id].join(' -> ');
          }
          seen.add(id);
          const value = this._findCycle(outgoing, seen);
          if (value) {
            return value;
          }
          seen.delete(id);
        }
        return undefined;
      }
    }
    复制代码
  6. 实现getService方法

    export default class InstantiationService {
      #serviceStore = new Map<string, unknown>();
    
      get services() {
        return this.#serviceStore;
      }
    
      constructor() {
        this.#serviceStore.set('instantiationService', this);
      }
    
      getService<T = any>(id: string): T {
        // has created, return exist service
        if (this.#serviceStore.has(id)) return this.#serviceStore.get(id) as T;
        return this.#createAndCacheService(id);
      }
    
      #createAndCacheService<T = any>(serviceId: string): T {
        const ServiceCtor = this.#getServiceCtorById(serviceId);
        if (!ServiceCtor) throw new Error(`[InstantiationService] service ${serviceId} not found!`);
    
        // build graph
        const graph = new Graph<{ serviceId: string; ctor: ServiceCtor }>((node) => node.serviceId);
        const stack = [{ ctor: ServiceCtor, serviceId }];
        while (stack.length) {
          const node = stack.pop()!;
          graph.lookupOrInsertNode(node);
          const dependencies: DependenciesValue = (this.#getServiceDependencies(node.ctor) || []).sort(
            (a, b) => a.parameterIndex - b.parameterIndex,
          );
          for (const dependency of dependencies) {
            if (this.#serviceStore.has(dependency.id)) continue;
            const ServiceCtor = this.#getServiceCtorById(dependency.id);
            const dependencyNode = { ctor: ServiceCtor, serviceId: dependency.id };
            if (!graph.lookup(dependencyNode)) {
              stack.push(dependencyNode);
            }
            graph.insertEdge(node, dependencyNode);
          }
        }
    
        while (true) {
          const roots = graph.roots();
          if (roots.length === 0) {
            if (!graph.isEmpty()) {
              throw new CyclicDependencyError(graph);
            }
            break;
          }
          for (const root of roots) {
            const { ctor: ServiceCtor, serviceId } = root.data;
            const dependencies = this.#getServiceDependencies(ServiceCtor) || [];
            const args = dependencies.map(({ id }) => this.getService(id));
            const service = new ServiceCtor(...args);
            this.#serviceStore.set(serviceId, service);
            graph.removeNode(root.data);
          }
        }
        return this.getService(serviceId);
      }
    
      #getServiceDependencies(Ctor: ServiceCtor): DependenciesValue {
        return Reflect.getOwnMetadata(dependencyMetadataKey, Ctor);
      }
    
      #getServiceCtorById(id: string): ServiceCtor {
        if (!serviceCtorStore.has(id)) {
          throw new Error(`service ${id} not found!`);
        }
        return serviceCtorStore.get(id)!;
      }
    }
    复制代码
    1. InstantiationService#serviceStore用于保存所有已初始化好的服务

    2. 如果#serviceStore能找到对应的服务,表示服务已经ready,可以直接使用,立即返回就好,否则调用createAndCacheService初始化这个服务。

    3. 根据ID从服务注册表中拿到当前ID对应的服务构造函数。

    4. 构建服务依赖图,这里使用一个栈来保存未插入图的服务,初始值为当前需要初始化的服务。每次从栈从取出一个值,并通过服务依赖关系表,遍历这个服务的所有依赖,根据依赖关系在图中插入对应的边,如果图中不存在这个依赖节点,则入栈,等待下次循环继续处理这个依赖节点的依赖。

    5. 服务依赖图构建好后,继续执行一个循环,每次循环拿出当前图的所有根节点,并根据之前记录的服务依赖关系(包含依赖服务ID已经构造函数形参索引),初始化此次循环的根节点,并将对应的服务实例存入serviceStore中,然后,在图中移除该根节点。

    6. 当图中的所有节点都被移除后,结束循环,返回调用getService的结果,此时serviceStore肯定存在对应的服务,走第二步中里立即返回逻辑。

到这里所有的核心代码实现都已经完成。

解决循环依赖

当依赖关系出现一个环时,此时就出现了循环依赖,初始化ServiceA的时候,依赖关系逐级查找,最终找回到了ServiceA,此时就出现了ServiceA 依赖 Service A的经典鸡生蛋蛋生鸡问题。这个场景在实际业务开发场景中是很常见的,我们来尝试解决下这个问题。

image.png

我们构造一个场景如上图,依赖关系是一个最简单的环形依赖,ServiceA --> ServiceC ---> ServiceB ---> ServiceA。

解决这种依赖关系的常见思路就是,我断开其中一个依赖关系,这个环形结构就被破坏了。

image.png

但是ServiceC 确实依赖ServiceB,这个问题要如何解决呢?我们可以分阶段来看这个问题,上面的依赖关系都是在初始化阶段就确定好的,如果ServiceC依赖ServiceB不在初始化阶段就直接依赖,我们就可以把这种依赖关系延后处理,即初始化阶段ServiceC不依赖ServiceB,只有在调用到某个方法时,才会依赖到ServiceB,按照这种思路,我们可以把这种依赖关系延后处理,即初始化不依赖,后续执行阶段依赖的这种关系。

上个章节的InstantiationService的实现中有个小细节,在InstantiationService构造函数中,InstantiationService把自己当前的实例也注册到serviceStore中了,所以我们可以用类似的方法将instantiationService实例注入到业务服务ServiceC中,这样在ServiceC中就可以通过getService方法来获取对应的ServiceB服务。

export default class InstantiationService {
  constructor() {
    this .#serviceStore.set( 'instantiationService' , this );
  }
 }
 
 // serviceC.ts
 @service('serviceC')
 class ServiceC {
   constructor(
     @inject('instantiationService')
     private readonly instantiationService: InstantiationService
   ) {
   }
   someMethod() {
     const serviceB = instantiationService.getService('serviceB');
     serviceB.xxx; // now, we can use serviceB to do anything serviceB can do.
   }
 }
复制代码

这样,循环引用的问题就解决啦!😄

总结

IOC模式在业务开发中可以很好的解决服务治理/依赖管理的问题,本文中的实现仅是一个最简单的完整IOC模式实现,希望对大家能有启发式的帮助。当然,本文中的实现还有很多可以优化的地方,如服务支持多例,初始化多次,支持自定义初始化参数,支持手动注册实例等。

仓库地址

github.com/heychenfq/e…

作者简介

陈富强,前端开发工程师阿强,目前就职于Tiktok国际化直播前端团队,专注于Web/Web Hybrid 研发方向,不定期更新,如果觉得这篇文章对你有所帮助,欢迎点赞加关注哦!

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改