Egg.js 源碼分析-項目啓動

前言

前端時間抽出時間針對Koa2源碼進行了簡單的學習,koa 源碼是一個很簡單的庫, 針對分析過程, 想手把手的實現一個類型koa 的框架,其代碼, 根據一步步的完善實現一個簡單版本的Koa, 每個步驟一個Branch , 如: stpe-1, 對應的是我想實現第一步的代碼, 代碼僅供本身簡單的學習,不少地方不完善,只是想體驗下Koa 的思想。下面幾點是我對Koa 的簡單理解:html

  • 全部的NodeJS 框架最基本的核心就是經過原生庫http or https啓動一個後端服務http.createServer(this.serverCallBack()).listen(...arg), 而後全部的請求都會進入serverCallBack方法, 而後咱們能夠經過攔截,在這個方法中處理不一樣的請求
  • Koa 是一個洋蔥模型, 其是基於中間件來實現的.經過use來添加一箇中間件, koa-router其實就是一個koa的中間件,咱們的全部的請求都會將全部的中間件都執行一遍,洋蔥模型以下圖所示

上面是我對Koa 源碼分析的一些簡單的理解, 後面我會將對Koa 的理解,進一步的記錄下來。 Koa 是一個很小巧靈活的框架, 不像Express, Express 已經集成了不少的功能, 不少功能再也不須要第三方的框架,好比說路由功能, Koa 須要引用第三方的庫koa-router 來實現路由等。可是express 則不須要,下面是Koa 和Express, 兩個實現一個簡單的功能的Demo , 咱們能夠比較下其使用方式:前端

// Express
const express = require('express')
const app = express()

app.get('/', function (req, res) {
  res.send('Hello World!')
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})
複製代碼
// Koa 
var Koa = require('koa');
// 引用第三方路由庫
var Router = require('koa-router');

var app = new Koa();
var router = new Router();
router.get('/', (ctx, next) => {
  // ctx.router available
});
// 應用中間件: router
app
  .use(router.routes())
  .use(router.allowedMethods());
app.listen(3000);
複製代碼

哈哈,咱們上面說了不少的廢話(文字表達能力問題), 其實我是想分析下,怎麼基於Koa 框架去應用, eggjs就是基於Koa 框架基礎上實現的一個框架, 咱們下面來具體分析下eggjs 框架。node

Eggjs 基本使用

咱們根據快速入門, 能夠很快搭建一個Egg 項目框架,git

$ npm i egg-init -g
$ egg-init egg-example --type=simple
$ cd egg-example
$ npm i
複製代碼

咱們能夠用npm run dev 快速啓動項目.而後打開localhost:7001,就能夠看到頁面輸出:github

hi, egg.express

說明咱們項目初始化已經完成,並且已經啓動成功。咱們如今能夠學習下egg項目生成的相關代碼。其代碼文件結構以下:npm

分析整個文件結構,找了整個項目都沒有發現app.js之類的入口文件(我通常學習一個新的框架,都會從入口文件着手), 發現app 文件夾下面的應該對項目很重要的代碼:json

1, controller文件夾,咱們從字面理解,應該是控制層的文件,其中有一個home.js 代碼以下:後端

'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    this.ctx.body = 'hi, egg';
  }
}

module.exports = HomeController;

複製代碼

這個類繼承了egg 的Controller 類, 暫時尚未發現這個項目哪一個地方有引用這個Controller 類?api

2, 一個router.js 文件, 從字面意義上咱們能夠理解其爲一個路由的文件,其代碼以下:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

複製代碼

這個文件暴露了一個方法, 從目前來猜想應該就是路由的一些配置, 可是找遍整個項目也沒有發現,哪一個地方引用了這個方法, router.get('/', controller.home.index);, 可是從這個get 方法的第二個參數, 其彷佛指向的是Controller 裏面的home.js 文件index 方法,咱們能夠嘗試修改下home.js 中的this.ctx.body = 'hi, egg -> hello world!';, 而後從新運行npm run dev, 發現頁面輸出是hi, egg -> hello world!, 看來controller.home.index這個指向的是home.js 裏的index 方法無疑了, 可是controller.home.index這個index 方法綁定的是在一個controller對象上,何時綁定的呢?

咱們接下來帶着以下疑問來學些eggjs :

  • 沒有相似的app.js 入口文件,運行npm run dev 如何啓動一個項目(啓動server, 監聽端口, 添加中間件)?
  • 咱們打開頁面http://localhost:7001/,怎麼去經過router.js 去查找路由的,而後調用對應的回調函數?
  • Controller 是如何綁定到app 上面的controller 對象上的?

eggjs 啓動

咱們先查看一開始用egg-init命令建立的項目的package.json 文件,查看scripts,裏面有一系列的命令,以下圖:

咱們能夠經過 npm run start來啓動程序, 可是其中有一個命令 debug, 咱們能夠能夠經過 npm run debug命令來調試eggjs 程序, 其對用的命令是 egg-bin debug, 因此咱們整個入口就是這個命令,咱們下面來具體分析下 egg-bin debug是如何工做的.

egg-bin

egg-bin 中的start-cluster文件, 調用了eggjs 的入口方法:require(options.framework).startCluster(options); 其中options.framework指向的就是一個絕對路徑D:\private\your_project_name\node_modules\egg(也就是egg 模塊), 直接執行D:\private\your_project_name\node_modules\egg\index.js暴露出來的exports.startCluster = require('egg-cluster').startCluster;startCluster方法。 下面咱們就來分析egg-cluster 模塊。

egg-cluster

egg-cluster 的項目結構以下, 其中有兩個主要的文件: master.js, app_worker.js兩個文件,

master.js是跟nodejs的多線程有關,咱們先跳過這一塊,直接研究app_worker.js文件,學習eggjs 的啓動過程。下面咱們就是app_worker.js 執行的主要步驟。

  1. const Application = require(options.framework).Application; , 引入eggjs 模塊, optons.framework 指向的就是D:\private\your_project_name\node_modules\egg
  2. const app = new Application(options);(建立一個 egg 實例)
  3. app.ready(startServer);調用egg 對象的** ready ** 方法,其startServer 是一個回調函數,其功能是調用nodejs 原生模塊http or httpscreateServer 建立一個nodejs 服務(server = require('http').createServer(app.callback());, 咱們後續會深刻分析這個方法)。

上面三個步驟, 已經啓動了一個nodejs 服務, 監聽了端口。也就是已經解決了咱們的第一個疑問:

沒有相似的app.js 入口文件,運行npm run dev 如何啓動一個項目(啓動server, 監聽端口, 添加中間件)?

上面其實咱們仍是隻是分析了eggjs啓動的基本流程, 尚未涉及eggjs 的核心功能庫,也就是** egg ** 和** egg-core** 兩個庫,可是咱們上面已經初實例化了一個eggjs 的對象const app = new Application(options);, 下面咱們就從這個入口文件來分析eggjs 的核心模塊。

egg & egg-core

egg 和egg-core 模塊下面有幾個核心的類,以下:

Application(egg/lib/applicaton.js) -----> EggApplication(egg/lib/egg.js) -----> EggCore(egg-core/lib/egg.js) -----> KoaApplication(koa)

從上面的關係能夠,eggjs 是基於koa的基礎上進行擴展的,因此咱們從基類的構造函數開始進行分析(由於new Application 會從繼類開始的構造函數開始執行)。

EggCore(egg-core/lib/egg.js)

咱們將構造函數進行精簡,代碼以下

從上圖可知,構造函數就是初始化了不少基礎的屬性,其中有兩個屬性很重要:

  1. this.lifecycle負責整個eggjs 實例的生命週期,咱們後續會深刻分析整個生命週期
  1. this.loader(egg-core/lib/loader/egg_loader.js)解決了eggjs 爲何在服務啓動後,會自動加載,將項目路徑下的router.js, controller/**.js, 以及service/**.js綁定到 app 實例上, 咱們接下來會重點分析這個loader.

EggApplication(egg/lib/egg.js)

咱們將構造函數進行精簡,代碼以下

這個構造函數一樣也是初始化了不少基礎的屬性, 可是其中有調用EggCore 構造函數初始化的loaderloadConfig() 方法, 這個方法顧名思義就是去加載配置,其指向的是: egg/lib/loader/app_worker_loader .js 的方法loadConfig , 這個方法,以下:

loadConfig() {
    this.loadPlugin();
    super.loadConfig();
  }

複製代碼

其會加載全部的Plugin ,而後就加載全部的Config.

this.loadPlugin() 指向的是egg-core/lib/loader/mixin/plgin.js的方法loadPlugin, 其會加載三種plugin:

  • const appPlugins = this.readPluginConfigs(path.join(this.options.baseDir, 'config/plugin.default'));,應用配置的plugin , 也就是your-project-name/config/plugin.js, 也就是每一個應用須要配置的特殊的插件
  • const eggPluginConfigPaths = this.eggPaths.map(eggPath => path.join(eggPath, 'config/plugin.default'));, 也就是從eggjs 框架配置的插件, 其路徑是在egg/config/plugin.js, 也就是框架自帶的插件
  • process.env.EGG_PLUGINS 第三種, 是啓動項目是,命令行帶參數EGG_PLUGINS的插件, 應該使用不廣。

最後將全部的plugin 掛在在app實例上this.plugins = enablePlugins;,。(後續會學習怎麼這些plugin 怎麼工做的。)

接下來會執行super.loadConfig()方法, 其指向的是egg-core/lib/loader/mixin/config.jsloadConfig()方法, 其一樣會加載四種config:

  • const appConfig = this._preloadAppConfig();, 應用配置的config , 也就是每一個應用的特殊配置,其會加載兩個配置:
const names = [
     'config.default',
     `config.${this.serverEnv}`,
   ];
複製代碼

第一個必定會加載對應的config.default配置, 也就是your-project-name/config/config.default.js,跟運行環境沒有關係的配置, 其次會加載跟運行環境有關的配置,如: config.prod.js, config.test.js, config.local.js, config.unittest.js

  • 會去加載全部的plugin 插件目錄
if (this.orderPlugins) {
     for (const plugin of this.orderPlugins) {
       dirs.push({
         path: plugin.path,
         type: 'plugin',
       });
     }
   }
複製代碼
  • 會去加載egg 項目目錄, 也就是egg/config 目錄
for (const eggPath of this.eggPaths) {
     dirs.push({
       path: eggPath,
       type: 'framework',
     });
   }
複製代碼
  • 回去加載應用項目的目錄, 也就是也就是your-project-name/config

最後將合併的config 掛載在app 實例上this.config = target;

咱們能夠打開egg/config/config.default.js文件,能夠查看下,默認的都有什麼配置,其中一個配置以下:

config.cluster = {
    listen: {
      path: '',
      port: 7001,
      hostname: '',
    },
  };
複製代碼

很明顯,這應該是一個對server 啓動的配置,咱們暫且能夠這樣猜想。

咱們上面有分析在egg-cluster/lib/app_worker.js中,咱們初始化app 後,咱們有調用app.ready(startServer);方法,咱們能夠猜想startServer方法就是啓動nodejs server 的地方。

startServer方法中,初始化了一個http server server = require('http').createServer(app.callback());, 而後咱們給listen server.listen(...args);;, 這樣算是node js 的server 啓動起來了, 咱們能夠查看下,我能夠查看args 的參數:

const args = [ port ];
      if (listenConfig.hostname) args.push(listenConfig.hostname);
      debug('listen options %s', args);
      server.listen(...args);
複製代碼

這裏給args 添加了prot 端口參數, 咱們能夠跳轉到prot定義的地方:

const app = new Application(options);
const clusterConfig = app.config.cluster || /* istanbul ignore next */ {};
const listenConfig = clusterConfig.listen || /* istanbul ignore next */ {};
const port = options.port = options.port || listenConfig.port;
複製代碼

咱們能夠看到port 最終來源於: app.config.cluster.listen.port,從這裏咱們得知, eggjs 的config 的使用方式。

問題:

若是咱們不想在eggjs 項目啓動時,默認打開的端口不是7001 ,咱們改怎麼操做呢?

咱們應該有以下兩種方式:

  1. 在執行npm run debug 命令時,添加相應的參數
  2. 咱們能夠在咱們項目的config/config.default.js 中添加配置,將默認的給覆蓋掉,如:
module.exports = appInfo => {
  const config = exports = {};

  // use for cookie sign key, should change to your own and keep security
  config.keys = appInfo.name + '_1541735701381_1116';

  // add your config here
  config.middleware = [];
  config.cluster = {
    listen: {
      path: '',
      port: 7788,
      hostname: '',
    },
  };
  return config;
};

複製代碼

如上,咱們再次啓動項目的時候,打開的端口就是: 7788了。

思考:

咱們已經知道能夠在config 中進行相應的配置了, 咱們還有什麼其餘的應用在config 上面呢?

咱們知道在不一樣的運行環境下,會加載不一樣的配置,那若是咱們在開發的時候,調用api 的路徑是: http://dev.api.com, 可是在上線的時候,咱們調用的app的路徑是: http://prod.api.com, 咱們就能夠在 config.prod.js中配置 apiURL:http://prod.api.com, 在config.local.js配置: apiURL:http://prod.api.com

而後咱們在咱們調用API的地方經過 app.apiURL就能夠。

Application(egg/lib/application.js)

Application(egg/lib/applicaton.js) -----> EggApplication(egg/lib/egg.js) -----> EggCore(egg-core/lib/egg.js) -----> KoaApplication(koa)

咱們已經將上述的兩個核心的類: EggApplication(egg/lib/egg.js) -----> EggCore(egg-core/lib/egg.js), 咱們如今來分析最上層的類: Application(egg/lib/applicaton.js)。

咱們仍是從構造函數入手,咱們發現了一行很重要的代碼this.loader.load();其指向的是: app_worker_loader.js(egg/lib/loader/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(); // Dependent on controllers
  }
複製代碼

從這個方法可知,加載了一大批的配置,咱們能夠進行一一的分析:

this.loadApplicationExtend();

這個方法會去給應用加載不少的擴展方法, 其加載的路徑是: app\extend\application.js, 會將對應的對象掛載在app 應用上。 (使用方法能夠參考egg-jsonp/app/extend/applicaton.js 或者egg-session/app/extend/application.js)

this.loadResponseExtend(); this.loadResponseExtend(); this.loadContextExtend(); this.loadHelperExtend();,

this.loadApplicationExtend();加載的方式是同樣的,只是對應的名稱分別是: request.js, response.js, helper.js, context.js

this.loadCustomApp();

定製化應用, 加載的文件是對應項目下的app.js (your_project_name/app.js), 其具體的代碼實現以下: (egg-core/lib/loader/mixin/custom.js)

[LOAD_BOOT_HOOK](fileName) {
    this.timing.start(`Load ${fileName}.js`);
    for (const unit of this.getLoadUnits()) { 
      const bootFilePath = this.resolveModule(path.join(unit.path, fileName));
      if (!bootFilePath) {
        continue;
      }
      const bootHook = this.requireFile(bootFilePath);
      // bootHook 是加載的文件
      if (is.class(bootHook)) {
        // if is boot class, add to lifecycle
        this.lifecycle.addBootHook(bootHook);
      } else if (is.function(bootHook)) {
        // if is boot function, wrap to class
        // for compatibility
        this.lifecycle.addFunctionAsBootHook(bootHook);
      } else {
        this.options.logger.warn('[egg-loader] %s must exports a boot class', bootFilePath);
      }
    }
    // init boots
    this.lifecycle.init();
    this.timing.end(`Load ${fileName}.js`);
  },
複製代碼

從上可知** bootHook** 對應的就是加載的文件,從上面的if else可知, app.js 必須暴露出來的是一個class 或者是一個function ,而後調用this.lifecycle.addFunctionAsBootHook(bootHook);, 其代碼以下:

addFunctionAsBootHook(hook) {
    assert(this[INIT] === false, 'do not add hook when lifecycle has been initialized');
    // app.js is export as a funciton
    // call this function in configDidLoad
    this[BOOT_HOOKS].push(class Hook {
      constructor(app) {
        this.app = app;
      }
      configDidLoad() {
        hook(this.app);
      }
    });
  }
複製代碼

將對應的hook push 到this.lifecycle 的BOOT_HOOKS 數組中, 而且包裝成了一個類, 且在configDidLoad 調用對應的hook.而後調用了this.lifecycle.init();去初始化生命週期:

init() {
    assert(this[INIT] === false, 'lifecycle have been init');
    this[INIT] = true;
    this[BOOTS] = this[BOOT_HOOKS].map(t => new t(this.app));
    this[REGISTER_BEFORE_CLOSE]();
  }
複製代碼

這個init 方法作了三件事情:

  • 將lifecycle 的INIT 狀態標記爲: true
  • 將BOOT_HOOKS 對應的類, 實例化一個對象,保存在BOOTS
  • 調用REGISTER_BEFORE_CLOSE方法,其中會調用咱們的hook 的beforeClose 方法。

this.loadCustomApp(); 方法以下:

loadCustomApp() {
    this[LOAD_BOOT_HOOK]('app');
    this.lifecycle.triggerConfigWillLoad();
  },
複製代碼

因此接下執行this.lifecycle.triggerConfigWillLoad();

triggerConfigWillLoad() {
    for (const boot of this[BOOTS]) {
      if (boot.configWillLoad) {
        boot.configWillLoad();
      }
    }
    this.triggerConfigDidLoad();
  }

  triggerConfigDidLoad() {
    for (const boot of this[BOOTS]) {
      if (boot.configDidLoad) {
        boot.configDidLoad();
      }
    }
    this.triggerDidLoad();
  }
複製代碼

其中boot.configDidLoad(); 就是咱們app.js 定義的hook, 被加工成的Hook 類:

class Hook {
      constructor(app) {
        this.app = app;
      }
      configDidLoad() {
        hook(this.app);
      }
    }
複製代碼

而後就將app.js 與eggjs 關聯起來了。

this.loadService();

查找的your_project_name/app/service/.js, 而後將文件名稱做爲一個做爲屬性,掛載在context**上下文上,而後將對應的js 文件,暴露的方法賦值在這個屬性上, 好比說咱們在以下路徑下: your_project_name/app/service/home.js, 其代碼以下:

'use strict';

// app/service/home.js
const Service = require('egg').Service;

class HomeService extends Service {
  async find() {
    // const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    const user = [
      {
        name: 'Ivan Fan',
        age: 18,
      },
    ];
    return user;
  }
}

module.exports = HomeService;
複製代碼

咱們在其餘的地方就能夠經過: this.ctx.service.home.find()方法調用service裏面的方法了,如在controller 中調用:

'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
  async index() {
    // this.ctx.body = 'hi, egg';
    this.ctx.body = await this.ctx.service.home.find();
  }
}
module.exports = HomeController;
複製代碼

this.loadMiddleware();

這個方法用來加載中間件,咱們後面會單獨來分析中間件

this.loadController();

這個方法是去加載controller , 其代碼以下:

loadController(opt) {
    this.timing.start('Load Controller');
    opt = Object.assign({
      caseStyle: 'lower',
      directory: path.join(this.options.baseDir, 'app/controller'),
      initializer: (obj, opt) => {
        // return class if it exports a function
        // ```js
        // module.exports = app => {
        //   return class HomeController extends app.Controller {};
        // }
        // ```
        if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj) && !is.asyncFunction(obj)) {
          obj = obj(this.app);
        }
        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);
        }
        // support generatorFunction for forward compatbility
        if (is.generatorFunction(obj) || is.asyncFunction(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);
    this.timing.end('Load Controller');
  },
複製代碼

其加載的路徑是: app/controller 下面的js 文件。而後將對應文件的名稱掛載在app.controller上面,而後就能夠經過以下方式,調用controller下面js 暴露的方法:

module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};
複製代碼

上面也就是解決了咱們一開始的疑問三:

  • Controller 是如何綁定到app 上面的controller 對象上的?

this.loadRouter();

這個方法,顧名思義就是去加載router, 其代碼以下:

loadRouter() {
    this.timing.start('Load Router');
    // 加載 router.js
    this.loadFile(this.resolveModule(path.join(this.options.baseDir, 'app/router')));
    this.timing.end('Load Router');
  },
複製代碼

只會加載對應項目下的app/router.js, 也就是路由應該只有一個入口文件.以下Demo:

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

複製代碼

如上代碼實現路由。可是咱們只是給對應的路由添加了方法, 可是如何去監聽路由變化,而後調用不一樣的方法呢? 這個涉及到koa 中間件的使用方法,咱們後續會單獨分析中間件, 以及koa-router

總結

  1. egg 的核心模塊包括Application(egg/lib/applicaton.js) -----> EggApplication(egg/lib/egg.js) -----> EggCore(egg-core/lib/egg.js) -----> KoaApplication(koa)
  2. eggjs 會經過loadConfig() 去加載配置文件
loadConfig() {
    this.loadPlugin();
    super.loadConfig();
  }
複製代碼
  1. 會經過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(); // Dependent on controllers
  }
複製代碼

計劃

  1. 梳理Eggjs的基本使用方法
  2. 中間件middleware的使用
  3. 路由router 的使用原理
  4. egg-cluster的分析
  5. egg 生命週期的分析
相關文章
相關標籤/搜索