Skip to content

基于InversifyJS的服务基础框架

引言

在开发Gepick时,我选择了 InversifyJS 作为依赖注入框架。为了更加统一服务的开发方式和注册方式,基于 InversifyJS,我开发了一套增强框架大幅简化了开发体验。客户端、服务端的实现都共用同一套服务基础系统。

服务访问

为了开发方便,我简化了服务的访问。你只需要创建服务访问标识,就可以直接利用服务访问标识直接访问对应服务。

ts
export interface ILogger {
  log(...args:any[]):void;
}
export const ILogger = createServiceIdentifier<ILogger>("Logger")

export class Logger extends AbstractService implements ILogger {
  log(...args:any):void {
    console.log(...args);
  }
}

export class App extends AbstractService {
  constructor(
    @ILogger protected readonly logger: ILogger
  ) {
    super()
  }
  
  start() {
    this.logger.log("app start");
  }
}

上面这段代码定义了一个Logger,为了访问这个logger服务,我通过 createServiceIdentifier<ILogger>("Logger")定义了服务的访问标识符ILogger

接下来你只需要往容器中注册Logger,就能够在其他服务当中使用它。比如,在这个例子当中,我在App当中通过ILogger注入logger服务并使用它。

在Gepick当中,根据实际情况和Inversifyjs的对应使用,我目前设计了三种服务访问标识符创建API:

API效果
createServiceIdentifier<T>(serviceName: string ,symbol): ServiceIdentifier<T> 创建默认的服务访问标识。如果服务没注册,使用它会抛出错误。
createOptionalServiceIdentifier<T>(serviceName: string,symbol): ServiceIdentifier<T>创建一个可选的服务访问标识。如果服务没注册,使用它不会抛出错误,经典案例:所有IContributionProvider的访问标识都是它创建的。
createNamedServiceIdentifier<T>(serviceName: string,symbol): ServiceUtil.ServiceIdentifierOverload<T>创建一个可带名字的服务访问标识。经典案例:ILogger的使用,可以用@ILogger,也可以用@ILogger("App")

服务标识

我提供了一个AbstractService抽象类替换了inversifyjs的@injectable来替代标识某个服务成为一个可注入的服务,同时它还是一个带有生命周期的服务。

ts
export interface ILogger {
  log(...args:any[]):void;
}
export const ILogger = createServiceIdentifier<ILogger>("Logger")

export class Logger extends AbstractService implements ILogger {
  log(...args:any):void {
    console.log(...args);
  }
}

export class App extends AbstractService {
  protected readonly _onAppStart = this._register(new Emitter<void>());
  public readonly onAppStart = this._onAppStart.event;
  
  constructor(
    @ILogger protected readonly logger: ILogger
  ) {
    super()
  }
  
  start() {
    this.logger.log("app start");
  }
  
  stop() {
    this.dispose();
  }
}

还是以logger服务为例子,上面这段代码通过Logger extends AbstractService,让Logger成为一个可注入的服务。接下来你只需要往容器中注册Logger,就能够在其他服务当中使用Logger。比如,在这个例子当中,我在App当中通过ILogger注入logger服务并使用它。

Gepick中的服务是具有生命周期的,protected readonly _onAppStart = this._register(new Emitter<void>()),我通过_registerAPI将事件注册到析构容器当中。我定义了app停止的逻辑:stop() { this.dispose() },在app停止的时候我可以通过app.stop()将析构容器释放,它是通过AbstractService提供的dispose析构函数实现的。

服务注册

我统一了服务的注册方式,为了注册一组服务,我提供了AbstractModule抽象类,一组希望注册到容器中的服务,可以通过实现一个具体子类继承AbstractModule,并使用Module装饰器定义需要注册的服务。

ts
export interface ILogger {
  log(...args:any[]):void;
}
export const ILogger = createServiceIdentifier<ILogger>("Logger");
export class Logger extends AbstractService implements ILogger {
  log(...args:any):void {
    console.log(...args);
  }
}

export interface IApp {
  start():void
}
export const IApp = createServiceIdentifier<IApp>("App");
export class App extends AbstractService {
  constructor(
    @ILogger protected readonly logger: ILogger
  ) {
    super()
  }
  
  start() {
    this.logger.log("app start");
  }
}

@Module({ services:[ Logger, App ] })
export class CoreModule extends AbstractModule {}

export const container = new ServiceContainer({ modules: [CoreModule]});

container.get<IApp>(IApp).start();

上面这段代码,我通过class CoreModule extends AbstractModule定义了一个服务模块,然后通过@Module({ services:[ Logger, App ] })定义了需要注册到容器中的服务。最后我通过const container = new ServiceContainer({ modules: [CoreModule]})将服务模块加载到容器当中,服务就正式注册完毕了。接下来我就可以使用服务,container.get<IApp>(IApp)就是我提供的第二种服务获取方式。因此你不仅可以通过直接使用访问标识符在类中注入服务,也可以通过IOC容器直接获取服务。

服务容器

所有服务都需要注册到IOC容器当中进行管理,Gepick针对Inversifyjs的Container进行了部分改造。

ts
@Module({ services:[ Logger, App ] })
export class CoreModule extends AbstractModule {}

@Module({ service: [ PluginService ] })
export class PluginModule extends AbstractModule {}

export class WebSocketEnpoint extends AbstractService {}
export IWebSocketEnpoint = WebSocketEnpoint;
export const IWebSocketEnpoint = createServiceIdentifier<IWebSocketEnpoint>("WebSocketEnpoint")

export const container = new ServiceContainer({ modules: [CoreModule]});
container.load(PluginModule)
container.bind<IWebSocketEnpoint>(IWebSocketEnpoint).to(WebSocketEnpoint)

上面这段代码中,我通过new ServiceContainer({ modules: [CoreModule]})定义了一个新的IOC容器,它接收的选项相比较Inversifyjs多了一个modules选项。如果你需要加载一组服务,那么你可以通过初始化容器的时候使用modules选项。比如这个例子,你也可以在实例化IOC容器后,通过container.load(PluginModule)来完成服务模块的注册。或者你可以直接通过container.bind<IWebSocketEnpoint>(IWebSocketEnpoint).to(WebSocketEnpoint)来注册单独的服务。

服务子容器(层级结构)

像一些应用场景,我需要创建服务子容器来进行服务隔离。比如:不同的浏览器Tab打开AI服务,我应该针对每一个连接Connection实现一套Connection Scope级别的服务。每个Connection都对应着一套相互隔离的服务,但这些服务的实现都是一样的,除此之外的其他服务应该是共享的。如何实现这个服务隔离需求呢?其实服务子容器的设计实现就能够有效地实现这个需求。对于需要隔离的服务就组成一个服务模块,然后让子容器分别加载这个服务模块,而不需要隔离的服务都是共享的服务,得益于Inversifyjs的设计,子容器天然可以共享来自父容器的服务,如此一来需求得以实现。

我同样对createChildAPI做了改造,它也多出来一个modules选项。

ts
// #region 共享服务
export const ILogger = createServiceIdentifier<ILogger>("Logger");
class Logger extends AbstractService {}

@Module({ services: [Logger] });
class CommonModule extends AbstractModule {}
// #endregion

// #region connection scope级别服务
export const IAIChat = createServiceIdentifier<IAIChat>("AIChat");
class AIChat extends AbstractService {
  private _usage = 0;
 
  constructor(
    @ILogger protected logger: ILogger
  ) {
    super();
  }
  
  get usage() { this.logger.log(this._usage) }
  set usage(v: number) { this._usage = v }
  
}

@Module({services: [AIChat] })
class AIModule extends AbstractModule {}
// #endregion

// #region 服务注册 + 使用服务
const root = new ServiceContainer({ modules: [CommonModule] });
const child1 = root.createChild({ modules: [AIModule] }) 
const child2 = root.createChild({ modules: [AIModule] })


const childAIChat1 = child1.get<IAIChat>(IAIChat);
const childAIChat2 = child2.get<IAIChat>(IAIChat);

childAIChat1.usage; // 0
childAIChat2.usage; // 0

childAIChat1.usage = 1;
childAIChat2.usage = 2;

childAIChat1.usage; // 1
childAIChat2.usage; // 2
// # endregion

如上示例,我实现了一个简单的服务隔离。子容器child1child2都有自己的AIChat服务,实现相同,却又是相互隔离的。同时它们又共享着root容器的logger服务。

ts
// #region 共享服务
export const ILogger = createServiceIdentifier<ILogger>("Logger");
class Logger extends AbstractService {}

@Module({ services: [Logger] });
class CommonModule extends AbstractModule {}
// #endregion

上面这段代码定义了一个共享的服务模块CommonModule,共享的服务有Logger。我希望这个模块内的服务都是子容器共享的。

ts
// #region connection scope级别服务
export const IAIChat = createServiceIdentifier<IAIChat>("AIChat");
class AIChat extends AbstractService {
  private _usage = 0;
 
  constructor(
    @ILogger protected logger: ILogger
  ) {
    super();
  }
  
  get usage() { this.logger.log(this._usage) }
  set usage(v: number) { this._usage = v }
  
}

@Module({services: [AIChat] })
class AIModule extends AbstractModule {}
// #endregion

上面这段代码定义了一个connection scope级别的服务模块AIModule,针对不同的子容器,会使用自己的服务列表,每个connection就对应着一个子容器。你可以将connection看成在浏览器打开了一个新的Tab标签加载同样一个项目。

ts
// #region 服务注册 + 使用服务
const root = new ServiceContainer({ modules: [CommonModule] });
const child1 = root.createChild({ modules: [AIModule] }) 
const child2 = root.createChild({ modules: [AIModule] })
// # endregion

上面这段代码,我创建了一个根容器root,并加载了共同的服务模块CommonModule。接着,我假设有2个connection,分别对应着child1child2。我让它们分别加载AIModule。然后我假设正在使用两个浏览器Tab使用项目:

ts
const childAIChat1 = child1.get<IAIChat>(IAIChat);
const childAIChat2 = child2.get<IAIChat>(IAIChat);

childAIChat1.usage; // 0
childAIChat2.usage; // 0

childAIChat1.usage = 1;
childAIChat2.usage = 2;

childAIChat1.usage; // 1
childAIChat2.usage; // 2

childAIChat1.usagechildAIChat2.usage会打印出当前的token使用,这里应该是0。因为我在实现AIChat内部有get usage() { this.logger.log(this._usage) }这样一段代码定义了这段逻辑。然后我模拟浏览器Tab使用AI服务,让childAIChat1.usage = 1childAIChat2.usage = 2。最后我再次打印两个容器中对应AIChat里头usage的使用情况,childAIChat1.usagechildAIChat2.usage。可以看到输出分别是1和2。

如此,我可以做到服务的隔离效果。最后,我用一段项目注释说明实际应用:

ts
/**
 * 有的时候,我需要动态创建模块,然后将其加载到容器中。这种场景下,我一样可以使用Contribution来实现这种功能。
 * 我通过createContribution来创建一个Contribution,再通过定义一个抽象类标记Contribution的实现类。接下来我分两步走:
 * - 实现具体的Contribution,来完成Module的创建逻辑
 * - 在需要这些贡献的Module的地方,通过IConnectionContainerModuleContributionProvider来获取所有Contribution,进而通过调用createModule来创建Module。这样一来,我就能够得到一组Module。
 * 接下来,你可以创建一个个子容器,分别加载这些Module,并获取其中的服务。通过这种操作,我让不同的容器之间都有同样的模块,但是又互不干扰,做到了模块的复用和隔离,这在实现支持多租户架构的系统时非常有用。
 *
 * 这种操作可以允许我实现如下场景:
 * - 多租户隔离:每个前端连接都有自己独立的服务实例
 * - 状态隔离:不同用户/连接之间的状态不会相互干扰
 *
 * 对于一些功能我就需要用到连接级别的模块,每个连接对应着一个子容器,每个子容器都能通过IConnectionScopeModuleContributionProvider来获取到一组模块贡献。
 * 虽然每个子容器都有同样的模块,但是它们之间是隔离的,互不干扰。
 *
 * ┌─────────────────────────────────────────────────────────────────┐
 * │                        总容器 (Root Container)                    │
 * │                                                                 │
 * │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
 * │  │   Connection A  │  │   Connection B  │  │   Connection C  │  │
 * │  │   子容器 A      │  │   子容器 B      │  │   子容器 C      │  │
 * │  │                 │  │                 │  │                 │  │
 * │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │
 * │  │ │  服务 A     │ │  │ │  服务 A     │ │  │ │  服务 A     │ │  │
 * │  │ │ (实例 A1)   │ │  │ │ (实例 A2)   │ │  │ │ (实例 A3)   │ │  │
 * │  │ └─────────────┘ │  │ └─────────────┘ │  │ └─────────────┘ │  │
 * │  │                 │  │                 │  │                 │  │
 * │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │
 * │  │ │  服务 B     │ │  │ │  服务 B     │ │  │ │  服务 B     │ │  │
 * │  │ │ (实例 B1)   │ │  │ │ (实例 B2)   │ │  │ │ (实例 B3)   │ │  │
 * │  │ └─────────────┘ │  │ └─────────────┘ │  │ └─────────────┘ │  │
 * │  │                 │  │                 │  │                 │  │
 * │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │ ┌─────────────┐ │  │
 * │  │ │  服务 C     │ │  │ │  服务 C     │ │  │ │  服务 C     │ │  │
 * │  │ │ (实例 C1)   │ │  │ │ (实例 C2)   │ │  │ │ (实例 C3)   │ │  │
 * │  │ └─────────────┘ │  │ └─────────────┘ │  │ └─────────────┘ │  │
 * │  └─────────────────┘  └─────────────────┘  └─────────────────┘  │
 * │                                                                 │
 * │  ┌─────────────────────────────────────────────────────────────┐ │
 * │  │                    共享服务层                                │ │
 * │  │                                                             │ │
 * │  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │ │
 * │  │  │  共享服务1  │  │  共享服务2  │  │  共享服务3  │         │ │
 * │  │  │ (单例)      │  │ (单例)      │  │ (单例)      │         │ │
 * │  │  └─────────────┘  └─────────────┘  └─────────────┘         │ │
 * │  └─────────────────────────────────────────────────────────────┘ │
 * └─────────────────────────────────────────────────────────────────┘
 *
 * 连接关系:
 * ├── 总容器 → Connection A → 子容器 A
 * ├── 总容器 → Connection B → 子容器 B
 * ├── 总容器 → Connection C → 子容器 C
 * └── 所有子容器 → 共享服务层 (依赖注入)
 *
 * 服务类型:
 * - 子容器服务:A、B、C (每个子容器独立实例)
 * - 共享服务:1、2、3 (所有子容器共享单例)
 */

Contribution机制

在实际的工程当中,很多服务都是可扩展的。比如一个CLI基础系统,我不太可能一次性就将所有options定义完,因为随着项目功能增加,不同的功能系统都有可能往CLI基础系统添加自己功能的options。这就需要我实现一个动态收集options的可扩展机制,这个机制就叫做Contribution机制。

通过Contribution机制,一个基础系统只需要实现自己的核心基础功能,然后定义出对应的Contribution扩展点,就可以在合适的时机将所有Contribution收集起来,将这些Contribution拿出来解析使用。

ts
export const [ICliContribution, ICliContributionProvider] = createContribution("CliContribution");

export interface ICliContribution {
  defineOptions(conf: yargs.Argv): void;
  processArguments(args: yargs.Arguments): MaybePromise<void>;
}
export type ICliContributionProvider = IContributionProvider<ICliContribution>;

@Contribution(ICliContribution)
export abstract class AbstractCliContribution extends AbstractService implements ICliContribution {
  abstract defineOptions(conf: yargs.Argv): void;

  abstract processArguments(args: yargs.Arguments): MaybePromise<void>;
}

上面这段代码,通过const [ICliContribution, ICliContributionProvider] = createContribution("CliContribution")定义了一个新的Contribution。它返回了两个变量,变量ICliContribution代表CliContribution的访问标识符,变量ICliContributionProvider代表CliContritbuion的服务列表访问标识符。

ts
@Contribution(ICliContribution)
export abstract class AbstractCliContribution extends AbstractService {}

上面这段代码,我通过AbstractCliContribution extends AbstractService 设计了一个抽象类,并通过@Contribution(ICliContribution)将它标识为CliContribution。这样一来,所有的CliContribution只需要继承AbstractCliContribution,并实现相关的方法就能够完成一个CliContribution的实现。最后同样地,你只需要将具体的CliContribution注册到IOC容器中就可以在需要收集CliContribution的地方使用ICliContributionProvider来获取所有CliContribution并使用了。

ts
export class CliService extends AbstractService {
  constructor(
    @ICliContributionProvider protected readonly cliContributionsProvider: ICliContributionProvider,
  ) {
    super();
  }

  async initCli(argv: string[], options: ICliServiceOptions): Promise<void> {
    const command = yargs(argv, process.cwd());

    const contribs = this.cliContributionsProvider.getContributions();
    // 配置选项
    for (const contrib of contribs) {
      contrib.defineOptions(command);
    }
    command
      .middleware(async (args) => {
        // 解析选项实际传入的参数值
        for (const contrib of contribs) {
          await contrib.processArguments(args);
        }
        await options.postProcessArguments();
      })
      .command('$0', false, () => {}, options.defaultCommand)
      .parse();
}

上面这段代码,@ICliContributionProvider protected readonly cliContributionsProvider: ICliContributionProvider注入了CliContributionContribution Provider,它是用来收集所有CliContribution的。我通过this.cliContributionsProvider.getContributions()收集所有的Contribution。每个Contrbution都有对应的defineOptionsprocessArguments,分别负责自己功能”选项的定义“和”选项的解析“。如果你希望更详细地了解CLI基础系统的具体设计实现,你可以参考我的相关文章。

服务装饰器

为了能够更好地使用服务,我实现了一套装饰器,它对应着大部分Inversifyjs里头的binding syntax规则。

BindToSyntaxBindInSyntaxBindOnSyntaxBindWhenSyntax
@ToConstantValue@InSingletonScope@OnActivation@When
@ToDynamicValue@InTransientScope@OnDeactivation
@ToFactory@InRequestScope
@ToProvider
  • BindToSyntax

    ts
    export class TestDynamicValue extends AbstractService {
      
      @ToDynamicValue()
      toDynamicValue({ container }: { container: IServiceContainer }) {
        return {
          command1: 'testcommand1',
          commandRegistry: container.get(ICommandRegistry),
        };
      }
    }
    
    export const ITestDynamicValue = createServiceIdentifier<ITestDynamicValue>(TestDynamicValue.name);
    export interface ITestDynamicValue {
      command1: string;
      commandRegistry: ICommandRegistry;
    }

    上面这段代码,通过@ToDynamicValue()将整个服务变成一个动态定制的服务,当你使用ITestDynamicValue访问服务时,实际上访问的是

    ts
    {
          command1: 'testcommand1',
          commandRegistry: container.get(ICommandRegistry),
    }

    因此你的服务类型也需要做下调整

    ts
    interface ITestDynamicValue {
      command1: string;
      commandRegistry: ICommandRegistry;
    }
  • BindInSyntax

    ts
    @InTransientScope()
    export class TestTransientScope extends AbstractService {}

    服务默认设计跟Inversifyjs不同,我将其设计成默认是Singleton级别,因此如果你需要修改服务的作用域,你可以使用作用域装饰器来修改。比如这里,我们使用@InTransientScope()将服务改成了Transient Scope

  • BindOnSyntax

    ts
    export type ITestOnActivation = TestOnActivation;
    export class TestOnActivation extends AbstractService {
      
      @OnActivation()
      onActivation(ctx: interfaces.Context, service: ITestOnActivation) {
        console.log("service active", service)
      }
    }

    Inversifyjs当中的onActivation的使用大致如下:

    ts
    bind(ILogger).to(Logger).onActivation((context, service) => {
            // do something...
            return service;
    });

    你需要时刻记得返回service,否则inversifyjs会给你提示一个错误。使用@OnActivation装饰器,就没有这个烦恼了,在内部实现其实就是劫持了inversifyjs原始的内容做了强制返回修改,其他使用方式都跟inversifyjs完全一样。

  • BindWhenSyntax

    ts
    @When(request => getName(request) === undefined)
    export class DefaultLogger extends AbstractService {
      static override name = "Logger";
      log() {
        console.log("default logger");
      }
    }
    
    @When(request => getName(request) !== undefined)
    export class DynamicLogger extends AbstractService {
      static override name = "Logger";
    
      @ToDynamicValue()
      toDynamicValue({ container }: { container: IServiceContainer }) {
        return {
          log() {
            console.log("dynamic logger");
          },
        };
      }
    }
    
    
    
    function getName(request: interfaces.Request): string | undefined {
      const named = request.target.metadata.find(e => e.key === 'named');
      const result = named ? named.value?.toString() : undefined;
    
      return result;
    }

    条件返回在依赖注入当中是十分有用的。比如,设计一个具有层次结构的Logger基础系统,我们希望按照如下使用:

    ts
    class App extends AbstractService {
      constructor(
        @ILogger protected rootLogger: ILogger,
        @ILogger("App") protected appLogger: ILogger
      ) {
        super()
      }
    }

    上面这段代码,我们在App当中定义了两个Logger,我们希望当使用@ILogger的时候,使用root全局logger,使用@ILogger("App")的时候使用App模块级别的logger。如何在项目中实现这个需求?答案其实就是这个分节当中的第一段代码,我们可以通过使用@When装饰器来完成这个需求。

    ts
    function getName(request: interfaces.Request): string | undefined {
      const named = request.target.metadata.find(e => e.key === 'named');
      const result = named ? named.value?.toString() : undefined;
    
      return result;
    }

    上面这段代码的意思是找出这个依赖注入请求,是否使用了@named()装饰器,如果有返回传入@named()装饰器的那个名字,比如你使用@named("App")getName的结果就是App,否则就是undefined

    ts
    @When(request => getName(request) === undefined)
    export class DefaultLogger extends AbstractService {}
    
    @When(request => getName(request) !== undefined)
    export class DynamicLogger extends AbstractService {}

    上面的代码使用了@When装饰器,它们的意思是:如果依赖注入请求经过getName处理,获取的值是undefined,那么请你使用DefaultLogger这个实现,否则使用DynamicLogger这个实现,注意这两个类只不过是一个Logger定义的两种不同实现,我们用static override name = "Logger"标记了这个事实。因此你才可以使用ILogger来统一完成不同实现的服务注入。

    总结

    这篇文章,我带你介绍了Gepick当中的服务注入框架,它是Gepick开发的基础核心,几乎所有服务的开发都是依赖这个框架。这篇文章会根据实际进行修改编辑,希望能够给使用inversifyjs的你带来一定的使用启发和二开思路。