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 框架的优势在哪。