Daruk 框架运行原理(1)

Daruk2.0 稳定版本已经release几个月了,有公司内部的同事在用,也有外部的一些企业在用,但是少有人对代码做运行原理分析,今天我就把这部分的空缺弥补上,让用户可以在使用 Daruk 的同时学到知识,了解框架机制,遇到问题可以给Daruk项目提 pr。

开始之前,我还要啰嗦几句,给不理解 Daruk 到底是个什么东西的同学简单的介绍下,这个框架我当初为什么要写,解决的是什么问题,背景是什么,这样才方便连续的把这一系列文章读下去。

在2017年底的时候吧,公司内部有一阵子非常频繁的在内部造一些 nodejs 的小系统,web 工具,当时大家都在用 koa2来开发,也没有什么规范可循,大家都随便写,随着项目的增多,维护成本急剧上升,而且没有统一的规范,我们的一些业务能力也无法复用,而且项目中用原生 js 写的有,用typescript 写的也有,十分混乱,所以 Daruk 的初衷一开始只是想做一个 web 业务框架的模型而已,方便公司内部人开发一些前端nodejs系统使用。

我相信这个痛点,很多中小公司都会有,之所以没有选择一些别人写好的轮子,当时的一个主要原因就是普遍的 nodejs web服务在17年都没有 ts 版,这也算是当时我们内部想做这个纯 ts web框架的原因。

那里面的1.4%是前端 example 中的 js 代码,所以整个项目可以说是一个纯粹的 ts 版项目。

背景就交代这么多吧,如果你最近刚好需要用 ts 来编写新的 web 项目,并且正在选型 web 框架,那么Daruk 也许可以帮你解决一些实实在在的问题的。

我下边讲运行原理是基于2.0版本的,在开始之前,可能你需要理解一下 IoC 和 DI 的概念,但是不懂也没关系,因为我在一开始写的时候也不是特别的懂。所以应该不影响大家理解我后边要说的,真的遇到不懂的地方,我还可以再详细展开,那么下边正文开始。

这篇文章主要分成两个部分给大家讲,既应用启动时和请求访问时,一般的 web 服务都是围绕这两个部分来进行展开的,今天这一篇我只讲第一部分。

一,应用启动时。

我们简单看一个 hello world 的事例代码:

import { controller, DarukContext, DarukServer, get, Next } from '../../src';

@controller()
class HelloWorld {
  @get('/')
  public async index(ctx: DarukContext, next: Next) {
    ctx.body = 'hello world';
  }
}

(async () => {
  let app = DarukServer();
  let port = 3000;
  await app.binding();
  app.listen(port);
  app.logger.info(`app listen port ${port}`);
})();

这里用到的 API 非常少,所以更适合带大家了解内部到底都干了什么,我画了一张图来解释:

我们先说第一行代码:

let app = DarukServer();

这一步框架主要完成的几件事:

我们一步一步分解来说,源码是这个样子的:

const DarukServer = (options?: PartialOptions) => {
  let instance = new Daruk();
  instance._initOptions(options);
  darukContainer.bind('Daruk').toConstantValue(instance);
  return instance;
};

为什么不把 options 直接扔到 Daruk 里,因为之前有遇到服务初始化之后,options 的部分需要调用异步方法来获取值后再初始化,所以这里留了一个原子的 `_initOptions` 方法,用户可以不使用 DarukServer,而自行拆分原子方法完成 instance 的实例化,封装这个 DarukServer 只是为了简化书写操作。

这部分代码中的 Daruk,darukContainer 在框架中都有对外 export,所以这里可以理解 DarukServer 是一个常用的简写函数。

然后我们看一下 Daruk 的构造函数是怎么定义的,一步一步来:

public constructor() {
    super();
    // 用于保存 DarukLoader 加载的模块
    this.app = new Koa();
    // tslint:disable-next-line
    const self = this;
    // 监听 koa 的错误事件,输出日志
    this.app.on('error', function handleKoaError(err: Error) {
      self.prettyLog('[koa error] ' + (err.stack || err.message), { level: 'error' });
    });
  }

首先 Daruk 继承了 EventEmitter 类,所以需要在 constructor 里调用下 super,然后我们初始化了 koa 服务保存在 Daruk 的 app 属性上,又订阅了 koa 的 error 事件,打印对应的错误日志。

很好理解,我们再来看 _initOptions 方法:

public _initOptions(options: PartialOptions = {}) {
    const rootPath = options.rootPath || dirname(require?.main?.filename as string);
    const defaultOptions = getDefaultOptions(rootPath, options.name, options.debug);
    const customLogger = options.customLogger;
    // customLogger 可能是一个类,不能进行 deep assign
    delete options.customLogger;
    this.options = deepAssign({}, defaultOptions, options);
    // 还原被 delete 的 customLogger
    this.options.customLogger = options.customLogger = customLogger;
    // 初始化 logger
    this.logger = customLogger || new KoaLogger.logger(this.options.loggerOptions);
    this.name = this.options.name;
  }

这个方法里做的事情也比较简单,首先初始化了项目的根路径,这很重要 rootPath 是后续加载其他 class 类文件时必须依赖的。然后就是定义了 Daruk 服务的 Logger,不自己定义就用默认的 KoaLogger,然后混淆 Daruk 的 所有 defaultOptions,服务本身是有一套默认初始化参数的。

看到这里还是比较清晰的结构,下边我讲一下 darukContainer 做的那一步 bind 是干什么用的。

const darukContainer = new Container({
  skipBaseClassChecks: true
});

darukContainer.bind('Daruk').toConstantValue(instance);

这里的 Container 是从 inversify 中拿到的,我们这里不过多解释 inversify,单纯看这几行代码的意思就是我实例化了一个全局的容器叫 darukContainer,然后我注入了一个名字叫 Daruk 的依赖,这个依赖的值是一个 Constant 值,就是我们项目中初始化后返回的 Daruk 实例。

这么做是为什么呢?好处就是我在外部定义用户 class 的时候,凡是通过 IoC 方式挂载的类,都可以很方便的利用 @inject 装饰器拿到 Daruk 实例,这非常重要,比如我要调用默认的 logger,我要拿服务的 options 等都会有这个可能,所以这里预留了这么一个依赖项。

当然写到这里我发现了一个隐藏的 bug,就是如果我再调用一次 DarukServer 的时候,Daruk 这个依赖就变成多个了,因为会重复做 bind 操作,这种情况在一般的情况下不会发生,一旦发生了,在 inversify 中取依赖的时候就可能拿到一个数组,这个数组会保存有2个 Daruk 实例,倒也还好,不过不建议这么去尝试,既在一个 web 服务中启动2次 Daruk app,建议拆成不同的服务项目来做这种需求。

下边我们讲一下这代码都做了什么:

await app.binding();

可以看到这个 binding 是一个异步操作,我们看一下源码,这个 binding 方法主要负责是把用户类和插件做加载:

public async binding() {
    await this._loadFile(join(__dirname, '../plugins'));
    await this._loadFile(join(__dirname, '../built_in'));
    const plugins = darukContainer.getAll(TYPES.PLUGINCLASS);
    for (let plugin of plugins) {
      let retValue = await plugin.initPlugin(this);
        darukContainer
          .bind(TYPES.PluginInstance)
          .toConstantValue(retValue)
          .whenTargetNamed(plugin.constructor.name);
    }
    this.emit('init', darukContainer);
    darukContainer.load(buildProviderModule());
  }

首先调用了2次 loadFile 方法,加载了 Daruk 内置的 plugins 和 middlewares,这里的 loadFIle 其实就是动态的 require 操作,让对应目录下的代码执行。

private async _loadFile(path: string) {
    return recursive(path).then((files) => {
      return files
        .filter((file) => isJsTsFile(file))
        .map((file) => file.replace(JsTsReg, ''))
        .forEach((path: string) => {
          require(path);
        });
    });
  }

loadFile 方法会对目录下的 ts 和 js 文件扫描,然后挨个 require 执行。

const plugins = darukContainer.getAll(TYPES.PLUGINCLASS);

这一行是从全局容器中获取所有的 plugin 类,那么这些 plugin 类是如何定义的呢?我们打开一个简单的 plugins 类来看一下,了解一下 Daruk 的 plugin 写法。

/**
 * @fileOverview 进程退出插件
 */

import ExitHook = require('daruk-exit-hook');
import { injectable } from 'inversify';
import Daruk from '../core/daruk';
import { plugin } from '../decorators';
import { PluginClass } from '../typings/daruk';

@plugin()
class DarukExitHook implements PluginClass {
  public async initPlugin(daruk: Daruk) {
    let exitHook = new ExitHook({
      onExit: (err: Error | null) => {
        if (err) {
          daruk.prettyLog(err.stack || err.message, { level: 'error' });
        }
        daruk.prettyLog('process is exiting');
        daruk.emit('exit', err, daruk);
      },
      onExitDone: (code: number) => {
        daruk.prettyLog(`process exited: ${code}`);
      }
    });
    return exitHook;
  }
}

上面的代码是 Daruk 内置的一个插件,监听进程退出行为的,我们可以看到我们使用了 @plugin 装饰器,装饰了一个 DarukExitHook 的类,然后声明了一个 initPlugin 的方法(基于约定),来 return 了一个 exitHook 实例。当然你可以 return 任何东西,或者在这个 initPlugin 做任何事情,关键在于这个 initPlugin 方法的回调参数,把 Daruk 实例做了完整返回,这就允许了用户或者框架开发者,可以把一些基础功能,拆解成这种 plugin 插件的模式,在 binding 周期中进行定义的能力,而且还可以访问到 Daruk 内部的一些属性和原型方法。

for (let plugin of plugins) {
      let retValue = await plugin.initPlugin(this);
        darukContainer
          .bind(TYPES.PluginInstance)
          .toConstantValue(retValue)
          .whenTargetNamed(plugin.constructor.name);
    }

这一步操作是得到所有注册后的 plugin 类,然后挨个调用约定好的 initPlugin 方法,然后再把对应的返回值(如果有)就绑定到全局 IoC 容器上,保证外部依赖可以拿到对应的插件值。

this.emit('init', darukContainer);
  darukContainer.load(buildProviderModule())

我们初始化完所有的 plugin 之后,触发一个容器全部完成 init 的生命周期,最后再调用一下用户自己定义的 provide 类。这里特指用户使用了 @provide 装饰器定义的类,也会自动挂载到全局容器下,这里利用了 inversify-binding-decorators 这个包来实现的。

最后就是这一行了:

app.listen(port);

源码我们定义了和 koa 一样的接口:

/**
   * @desc 启动服务
   */
  public listen(
    port?: number,
    hostname?: string,
    backlog?: number,
    listeningListener?: () => void
  ): Server;
  public listen(port: number, hostname?: string, listeningListener?: () => void): Server;
  public listen(port: number, backlog?: number, listeningListener?: () => void): Server;
  public listen(port: number, listeningListener?: () => void): Server;
  public listen(path: string, backlog?: number, listeningListener?: () => void): Server;
  public listen(path: string, listeningListener?: () => void): Server;
  public listen(handle: any, backlog?: number, listeningListener?: () => void): Server;
  public listen(handle: any, listeningListener?: () => void): Server;
  public listen(options: ListenOptions, listeningListener?: () => void): Server;
  public listen(...args: any[]): Server {
    // @ts-ignore
    this.httpServer = this.app.listen.apply(this.app, args);
    this.emit('serverReady', this.httpServer);
    return this.httpServer;
  }

所以其实你这里理解就是调用了 koa 实例的 listen 方法而已,然后返回了 koa 的 listen 返回值,并触发了框架 serverReady 的生命周期(插件中可能会订阅)。

最后我们说一下那个 hello world 的 controller 是怎么和 koa 的路由关联上的,首先 @controller 这个装饰器我们也把他的元数据进行了保存,然后在对应的 router 插件中进行了调用和定义,具体是怎么走的,我会在下一篇文章中给大家讲一下对应的流程了。

文章到这里已经挺长的了,但是通篇看下来没有什么很难理解地方,Daruk 本身的应用启动时做的事情就这么多,core 部分很小巧,大部分的框架能力我都定义到了对应的框架中间件和 plugin 中进行了实现。

最后,感谢阅读,在下一篇文章中,我们具体聊一聊 Daruk 的请求链路周期中干了什么,对应的 koa router 又是如何和 IoC 容器做了关联,Daruk 编写对应的 service 和 controller 相比其他 web 框架的优势在哪。