Egg运行原理

从egg创建工程起,并没有明确的入口文件,找了半天才大概明白,于是把过程写下来
参考https://github.com/SunShinewyf/issue-blog/issues/30

关于egg

egg是阿里开源的一个框架,为企业级框架和应用而生,相较于express和koa,有更加严格的目录结构和规范,使得团队可以在基于egg定制化自己的需求或者根据egg封装出适合自己团队业务的更上层框架

egg定位

天猪曾经在这篇优秀的博文中给出关于egg的定位,如下图:

egg.png

可以看到egg处于的是一个中间层的角色,基于koa,不同于koamiddleware为主要生态,egg根据不同的业务需求和场景,加入了plugin,extends等这些功能,可以让开发者摆脱在使用middleware功能时无法控制使用顺序的被动状态,而且还可以增加一些请求无关的一些功能。除此之外,egg还有很多其他优秀的功能,在这里不详述。想了解更多可以移步这里

初始化项目

egg有直接生成整个项目的脚手架功能,只需要执行如下几条命令,就可以生成一个新的项目:

$ npm i egg-init -g
$ egg-init helloworld --type=simple
$ cd egg-helloworld
$ npm i

启动项目:

$ npm run dev
$ open localhost:7001

egg是如何运行起来的

下面通过追踪源码来讲解一下egg究竟是如何运行起来的:

查看egg-init脚手架生成的项目文件,可以看到整个项目文件是没有严格意义上的入口文件的,根据package.json中的script命令,可以看到执行的直接是egg-bin dev的命令。找到egg-bin文件夹中的dev.js,会看到里面会去执行start-cluster文件:

//dev.js构造函数中
this.serverBin = path.join(__dirname, '../start-cluster');
// run成员函数
* run(context) {
    //省略
    yield this.helper.forkNode(this.serverBin, devArgs, options);
}

移步到start-cluster.js文件,可以看到关键的一行代码:

require(options.framework).startCluster(options);

其中options.framework打印信息为:
/Users/wyf/Project/egg-example/node_modules/egg

找到对应的egg目录中的index.js文件:

exports.startCluster = require('egg-cluster').startCluster;

继续追踪可以看到最后运行的其实就是egg-cluster中的startCluster,并且会forkagentWorkerappWorks官方文档对于不同进程的fork顺序以及不同进程之间的IPC有比较清晰的说明,
主要的顺序如下:

  • Master 启动后先 fork Agent 进程
  • Agent 初始化成功后,通过 IPC 通道通知 Master
  • Master 再 fork 多个 App Worker
  • App Worker 初始化成功,通知 Master
  • 所有的进程初始化成功后,Master 通知 Agent 和 Worker 应用启动成功

通过代码逻辑也可以看出它的顺序:

//在egg-ready状态的时候就会执行进程之间的通信
this.ready(() => {
  //省略代码
  const action = 'egg-ready';
  this.messenger.send({ action, to: 'parent' });
  this.messenger.send({ action, to: 'app', data: this.options });
  this.messenger.send({ action, to: 'agent', data: this.options });
});
    
this.on('agent-exit', this.onAgentExit.bind(this));
this.on('agent-start', this.onAgentStart.bind(this));
this.on('app-exit', this.onAppExit.bind(this));
this.on('app-start', this.onAppStart.bind(this));
this.on('reload-worker', this.onReload.bind(this));

// fork app workers after agent started
this.once('agent-start', this.forkAppWorkers.bind(this));

通过上面的代码可以看出,master进程会去监听当前的状态,比如在检测到agent-start的时候才去fork AppWorkers,在当前状态为egg-ready的时候,会去执行如下的进程之间的通信:

master ---> parent
master ---> agent
master ---> app

fork出了appWorker之后,每一个进程就开始干活了,在app_worker.js文件中,可以看到进程启动了服务,具体代码:

//省略代码
function startServer() {
  let server;
  if (options.https) {
    server = require('https').createServer({
      key: fs.readFileSync(options.key),
      cert: fs.readFileSync(options.cert),
    }, app.callback());
  } else {
    server = require('http').createServer(app.callback());
  }
 //省略代码
}

然后就回归到koa中的入口文件干的事情了。

除此之外,每一个appWorker还实例化了一个Application:

const Application = require(options.framework).Application;
const app = new Application(options);

在实例化application(options)时,就会去执行node_modules->egg模块下面loader目录下面的逻辑,也就是agentWorker进程和多个appWorkers进程要去执行的加载逻辑,具体可以看到app_worker_loader.js文件中的load():

load() {
    // app > plugin > core
    this.loadApplicationExtend();
    this.loadRequestExtend();
    this.loadResponseExtend();
    this.loadContextExtend();
    this.loadHelperExtend();

    // app > plugin
    this.loadCustomApp();
    // app > plugin
    this.loadService();
    // app > plugin > core
    this.loadMiddleware();
    // app
    this.loadController();
    // app
    this.loadRouter(); // 依赖 controller
  }
}

这也是下面要讲的东西了

在真正执行业务代码之前,egg会先去干下面一些事情:

加载插件
egg中内置了如下一系列插件:

  • onerror 统一异常处理
  • Session Session 实现
  • i18n 多语言
  • watcher 文件和文件夹监控
  • multipart 文件流式上传
  • security 安全
  • development 开发环境配置
  • logrotator 日志切分
  • schedule 定时任务
  • static 静态服务器
  • jsonp jsonp 支持
  • view 模板引擎

加载插件的逻辑是在egg-core里面的plugin.js文件,先看代码:

loadPlugin() {

    //省略代码
    //把本地插件,egg内置的插件以及app的框架全部集成到allplugin中
    this._extendPlugins(this.allPlugins, eggPlugins);
    this._extendPlugins(this.allPlugins, appPlugins);
    this._extendPlugins(this.allPlugins, customPlugins);
    
    //省略代码
    //遍历操作
    for (const name in this.allPlugins) {
      const plugin = this.allPlugins[name];

      //对插件名称进行一些校验
      this.mergePluginConfig(plugin);
      //省略代码
      }
      if (plugin.enable) {
        //整合所有开启的插件
        enabledPluginNames.push(name);
      }
 }

如上代码(只是贴出了比较关键的地方),这段代码主要是将本地插件、egg中内置的插件以及应用的插件进行了整合。其中this.allPlugins的结果如下:


egg2.png

可以看出,this.allPlugins包含了所有内置的插件以及本地开发者自定义的插件。先获取所有插件的相关信息,然后将所有插件进行遍历,执行this.mergePluginConfig()函数,这个函数主要是对插件名称进行一些校验。之后还对项目中已经开启的插件进行整合。plugin.js文件还做了一些其他事情,比如获取插件路径,读取插件配置等等,这里不一一讲解。

扩展内置对象

包括插件里面定义的扩展以及开发者自己写的扩展,这也是这里讲的内容。

在对内置对象进行扩展的时候,实质上执行的是extend.js文件,扩展的对象包括如下几个:

  • Application
  • Context
  • Request
  • Response
  • Helper

通过阅读extend.js文件可以知道,其实最后每个对象的扩展都是直接调用的loadExtends这个函数。拿Application这个内置对象进行举例:

loadExtend(name, proto) {
    // All extend files
    const filepaths = this.getExtendFilePaths(name);
    // if use mm.env and serverEnv is not unittest
    const isAddUnittest = 'EGG_MOCK_SERVER_ENV' in process.env && this.serverEnv !== 'unittest';
    for (let i = 0, l = filepaths.length; i < l; i++) {
      const filepath = filepaths[i];
      filepaths.push(filepath + `.${this.serverEnv}.js`);
      if (isAddUnittest) filepaths.push(filepath + '.unittest.js');
    }

    const mergeRecord = new Map();
    for (let filepath of filepaths) {
      filepath = utils.resolveModule(filepath);
      if (!filepath) {
        continue;
      } else if (filepath.endsWith('/index.js')) {
        // TODO: remove support at next version
        deprecate(`app/extend/${name}/index.js is deprecated, use app/extend/${name}.js instead`);
      }
      const ext = utils.loadFile(filepath);
      
      //获取内置对象的原有属性
      const properties = Object.getOwnPropertyNames(ext)
        .concat(Object.getOwnPropertySymbols(ext));
        
        //对属性进行遍历
      for (const property of properties) {
        if (mergeRecord.has(property)) {
          debug('Property: "%s" already exists in "%s",it will be redefined by "%s"',
            property, mergeRecord.get(property), filepath);
        }

        // Copy descriptor
        let descriptor = Object.getOwnPropertyDescriptor(ext, property);
        let originalDescriptor = Object.getOwnPropertyDescriptor(proto, property);
        if (!originalDescriptor) {
          // try to get descriptor from originalPrototypes
          const originalProto = originalPrototypes[name];
          if (originalProto) {
            originalDescriptor = Object.getOwnPropertyDescriptor(originalProto, property);
          }
        }
       //省略代码
       //将扩展属性进行合并
        Object.defineProperty(proto, property, descriptor);
        mergeRecord.set(property, filepath);
      }
      debug('merge %j to %s from %s', Object.keys(ext), name, filepath);
    }
},

将filepaths进行打印,如下图:


egg3.png

可以看出,filepaths包含所有的对application扩展的文件路径,这里会首先将所有插件中扩展或者开发者自己自定义的扩展文件的路径获取到,然后进行遍历,并且对内置对象的一些原有属性和扩展属性进行合并,此时对内置对象扩展的一些属性就会添加到内置对象中。所以在执行业务代码的时候,就可以直接通过访问application.属性(或方法)进行调用。

加载中间件
对中间件的加载主要是执行的egg-core中的middleware.js文件,里面的代码思想也是和上面加载内置对象是一样的,也是将插件中的中间件和应用中的中间件路径全部获取到,然后进行遍历。

遍历完成之后执行中间件就和koa一样了,调用co进行包裹遍历。

加载控制器
对控制器的加载主要是执行的egg-core中的controller.js文件
egg的官方文档中,插件的开发这一节提到:

插件没有独立的 router 和 controller

所以在加载controller的时候,主要是load应用里面的controller即可。详见代码;

loadController(opt) {
    opt = Object.assign({
      caseStyle: 'lower',
      directory: path.join(this.options.baseDir, 'app/controller'),
      initializer: (obj, opt) => {
        if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj)) {
          obj = obj(this.app);
        }
        if (is.promise(obj)) {
          const displayPath = path.relative(this.app.baseDir, opt.path);
          throw new Error(`${displayPath} cannot be async function`);
        }
        if (is.class(obj)) {
          obj.prototype.pathName = opt.pathName;
          obj.prototype.fullPath = opt.path;
          return wrapClass(obj);
        }
        if (is.object(obj)) {
          return wrapObject(obj, opt.path);
        }
        if (is.generatorFunction(obj)) {
          return wrapObject({ 'module.exports': obj }, opt.path)['module.exports'];
        }
        return obj;
      },
    }, opt);
    const controllerBase = opt.directory;

    this.loadToApp(controllerBase, 'controller', opt);
    this.options.logger.info('[egg:loader] Controller loaded: %s', controllerBase);
},

这里主要是针对controller的类型进行判断(是否是Object,class,promise,generator),然后分别进行处理

加载service
加载service的逻辑是egg-core中的service.js,service.js这个文件比较简单,代码如下:

loadService(opt) {
    // 载入到 app.serviceClasses
    opt = Object.assign({
      call: true,
      caseStyle: 'lower',
      fieldClass: 'serviceClasses',
      directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')),
    }, opt);
    const servicePaths = opt.directory;
    this.loadToContext(servicePaths, 'service', opt);
  },

首先也是先获取所有插件和应用中声明的service.js文件目录,然后执行this.loadToContext()

loadToContext()定义在egg-loader.js文件中,继续追踪,可以看到在loadToContext()函数中实例化了ContextLoader并执行了load(),其中ContextLoader继承自FileLoader,而且load()是声明在FileLoader类中的。
通过查看load()代码可以发现里面的逻辑也是将属性添加到上下文(ctx)对象中的。也就是说加载context对象是在加载service的时候完成的。

而且值得一提的是:在每次刷新页面重新加载或者有新的请求的时候,都会去执行context_loader.js里面的逻辑,也就是说ctx上下文对象的内容会随着每次请求而发生改变,而且service对象是挂载在ctx对象下面的,对于service的更新,这里有一段代码:

// define ctx.service
Object.defineProperty(app.context, property, {
  get() {
    // distinguish property cache,
    // cache's lifecycle is the same with this context instance
    // e.x. ctx.service1 and ctx.service2 have different cache
    if (!this[CLASSLOADER]) {
      this[CLASSLOADER] = new Map();
    }
    const classLoader = this[CLASSLOADER];
     
     //先判断是否有使用
    let instance = classLoader.get(property);
    if (!instance) {
      instance = getInstance(target, this);
      classLoader.set(property, instance);
    }
    return instance;
  },
});

在更新service的时候,首先会去获取service是否挂载在ctx中,如果没有,则直接返回,否则实例化service,这也就是service模块中的延迟实例化

加载路由
加载路由的逻辑主要是egg-core中的router.js文件

loadRouter() {
    // 加载 router.js
    this.loadFile(path.join(this.options.baseDir, 'app/router.js'));
},

可以看出很简单,只是加载应用文件下的router.js文件

加载配置
直接加载配置文件并提供可配置的方法。

设置应用信息
对egg应用信息的设置逻辑是对应的egg-core中的egg-loader.js,里面主要是提供一些方法获取整个app的信息,包括appinfo,name,path等,比较简单,这里不一一列出

执行业务逻辑
然后就会去执行如渲染页面等的逻辑

总结
这里只是我个人针对源代码以及断点调试总结的一些东西.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343