Egg 源码分析之 egg-core(二)

818 阅读13分钟
原文链接: zhuanlan.zhihu.com

我们接着

张佃鹏:Egg 源码分析之 egg-core(一)

继续分析接下来的三个 load 函数 loadService,loadController,loadRouter 的源码实现:

loadService 函数

如何在 Egg 框架中使用 service

loadService 函数的实现是所有load函数中最复杂的一个,我们不着急看源码,先看一下 service 在 Egg 框架中如何使用

// egg-core 源码 -> 如何在 egg 框架中使用 service

//方式 1 :app/service/user1.js
//这个是最标准的做法,导出一个 class ,这个 class 继承了 require('egg').Service ,其实也就是我们上文提到的 eggCore 导出的 BaseContextClass
//最终我们在业务逻辑中获取到的是这个class的一个实例,在 load 的时候是将 app.context 当作新建实例的参数
//在 controller 中调用方式:this.ctx.service.user1.find(1)
const Service = require('egg').Service;
class UserService extends Service {
  async find(uid) {
    //此时我们可以通过 this.ctx,this.app,this.config,this.service 获取到有用的信息,尤其是 this.ctx 非常重要,每个请求对应一个 ctx,我们可以查询到当前请求的所有信息
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}
module.exports = UserService;

//方式 2 :app/service/user2.js
//这个做法是我模拟了一个 BaseContextClass,当然也就可以实现方法 1 的目的,但是不推荐
class UserService {
  constructor(ctx) {
    this.ctx = ctx;
    this.app = ctx.app;
    this.config = ctx.app.config;
    this.service = ctx.service;
  }
  async find(uid) {
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    return user;
  }
}
module.exports = UserService;

//方式 3 :app/service/user3.js
// service 中也可以 export 函数,在 load 的时候会主动调用这个函数,把 appInfo 参数传入,最终获取到的是函数返回结果
//在 controller 中调用方式:this.ctx.service.user3.getAppName(1) ,这个时候在 service 中获取不到当前请求的上下文 ctx
module.exports = (appInfo) => {
    return {
        async getAppName(uid){
            return appInfo.name;
        }
    }
};

//方式 4 :app/service/user4.js
// service 也可以直接 export 普通的原生对象,load 的时候会将该普通对象返回,同样获取不到当前请求的上下文 ctx
//在 controller 中调用方式:this.ctx.service.user4.getAppName(1)
module.exports = {
    async getAppName(uid){
        return appInfo.name;
    }
};

我们上面列举了 service 下的 js 文件的四种写法,都是从每次请求的上下文 this.ctx 获取到 service 对象,然后就可以使用到每个 service 文件导出的对象了,这里主要有两个地方需要注意:

  1. 为什么我们可以从每个请求的 this.ctx 上获取到 service 对象呢:
    看过 Koa 源码的同学知道,this.ctx 其实是从 app.context 继承而来,所以我们只要把 service 绑定到 app.context 上,那么当前请求的上下文 ctx 自然可以拿到 service 对象,EggLoader 也是这样做的
  2. 针对上述四种使用场景,具体导出实例是怎么处理的呢?
  • 如果导出的是一个类,EggLoader 会主动以 ctx 对象去初始化这个实例并导出,所以我们就可以直接在该类中使用 this.ctx 获取当前请求的上下文了
  • 如果导出的是一个函数,那么 EggLoader 会以 app 作为参数运行这个函数并将结果导出
  • 如果是一个普通的对象,直接导出

FileLoader 类的实现分析

在实现 loadService 函数时,有一个基础类就是 FileLoader ,它同时也是 loadMiddleware,loadController 实现的基础,这个类提供一个 load 函数根据目录结构和文件内容进行解析,返回一个 target 对象,我们可以根据文件名以及子文件名以及函数名称获取到 service 里导出的内容,target 结构类似这样:

{
    "file1": {
        "file11": {
            "function1": a => a
        }
    },
    "file2": {
        "function2": a => a
    }
}

下面我们先看一下 FileLoader 这个类的实现:

// egg-core 源码 -> FileLoader 实现

class FileLoader {
  constructor(options) {
    /* options 里几个重要参数的含义:
    1. directory: 需要加载文件的所有目录
    2. target: 最终加载成功后的目标对象
    3. initializer:一个初始化函数,对文件导出内容进行初始化,这个在 loadController 实现时会用到
    4. inject:如果某个文件的导出对象是一个函数,那么将该值传入函数并执行导出,一般都是 this.app
    */
    this.options = Object.assign({}, defaults, options);
  }
  load() {
    //解析 directory 下的文件,下面有 parse 函数的部分实现
    const items = this.parse();
    const target = this.options.target;
    // item1 = { properties: [ 'a', 'b', 'c'], exports1 },item2 = { properties: [ 'a', 'b', 'd'], exports2 }
    // => target = {a: {b: {c: exports1, d: exports2}}}
    //根据文件路径名称递归生成一个大的对象 target ,我们通过 target.file1.file2 就可以获取到对应的导出内容
    for (const item of items) {
      item.properties.reduce((target, property, index) => {
        let obj;
        const properties = item.properties.slice(0, index + 1).join('.');
        if (index === item.properties.length - 1) {
          obj = item.exports;
          if (obj && !is.primitive(obj)) {
            //这步骤很重要,确定这个 target 是不是一个 exports ,有可能只是一个路径而已
            obj[FULLPATH] = item.fullpath;
            obj[EXPORTS] = true;
          }
        } else {
          obj = target[property] || {};
        }
        target[property] = obj;
        return obj;
      }, target);
    }
    return target;
  }

  //最终生成 [{ properties: [ 'a', 'b', 'c'], exports,fullpath}] 形式, properties 文件路径名称的数组, exports 是导出对象, fullpath 是文件的绝对路径
  parse() {
    //文件目录转换为数组
    let directories = this.options.directory;
    if (!Array.isArray(directories)) {
      directories = [ directories ];
    }
    //遍历所有文件路径
    const items = [];
    for (const directory of directories) {
      //每个文件目录下面可能还会有子文件夹,所以 globby.sync 函数是获取所有文件包括子文件下的文件的路径
      const filepaths = globby.sync(files, { cwd: directory });
      for (const filepath of filepaths) {
        const fullpath = path.join(directory, filepath);
        if (!fs.statSync(fullpath).isFile()) continue;
        //获取文件路径上的以 "/" 分割的所有文件名,foo/bar.js => [ 'foo', 'bar' ],这个函数会对 propertie 同一格式,默认为驼峰
        const properties = getProperties(filepath, this.options);
        // app/service/foo/bar.js => service.foo.bar
        const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
        // getExports 函数获取文件内容,并将结果做一些处理,看下面实现
        const exports = getExports(fullpath, this.options, pathName);
        //如果导出的是 class ,会设置一些属性,这个属性下文中对于 class 的特殊处理地方会用到
        if (is.class(exports)) {
          exports.prototype.pathName = pathName;
          exports.prototype.fullPath = fullpath;
        }
        items.push({ fullpath, properties, exports });
      }
    }
    return items;
  }
}

//根据指定路径获取导出对象并作预处理
function getExports(fullpath, { initializer, call, inject }, pathName) {
  let exports = utils.loadFile(fullpath);
  //用 initializer 函数对exports结果做预处理
  if (initializer) {
    exports = initializer(exports, { path: fullpath, pathName });
  }
  //如果 exports 是 class,generatorFunction,asyncFunction 则直接返回    
  if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
    return exports;
  }
  //如果导出的是一个普通函数,并且设置了 call=true,默认是 true,会将 inject 传入并调用该函数,上文中提到过好几次,就是在这里实现的
  if (call && is.function(exports)) {
    exports = exports(inject);
    if (exports != null) {
      return exports;
    }
  }
  //其它情况直接返回
  return exports;
}

ContextLoader 类的实现分析

上文中说到 loadService 函数其实最终把 service 对象挂载在了 app.context 上,所以为此提供了 ContextLoader 这个类,继承了 FileLoader 类,用于将 FileLoader 解析出来的 target 挂载在 app.context 上,下面是其实现:

// egg-core -> ContextLoader 类的源码实现

class ContextLoader extends FileLoader {
    constructor(options) {
        const target = options.target = {};
        super(options);
        // FileLoader 已经讲过 inject 就是 app
        const app = this.options.inject;
        // property 就是要挂载的属性,比如 "service"
        const property = options.property;
        //将 service 属性挂载在 app.context 上
        Object.defineProperty(app.context, property, {
            get() {
                //做缓存,由于不同的请求 ctx 不一样,这里是针对同一个请求的内容进行缓存
                if (!this[CLASSLOADER]) {
                    this[CLASSLOADER] = new Map();
                }
                const classLoader = this[CLASSLOADER];
                //获取导出实例,这里就是上文用例中获取 this.ctx.service.file1.fun1 的实现,这里的实例就是 this.ctx.service,实现逻辑请看下面的 getInstance 的实现
                let instance = classLoader.get(property);
                if (!instance) {
                    //这里传入的 this 就是为了初始化 require('egg').Service 实例时当作参数传入
                    // this 会根据调用者的不同而改变,比如是 app.context 的实例调用那么就是 app.context ,如果是 app.context 子类的实例调用,那么就是其子类的实例
                    //就是因为这个 this ,如果 service 里继承require('egg').Service ,才可以通过 this.ctx 获取到当前请求的上下文
                    instance = getInstance(target, this);
                    classLoader.set(property, instance);
                }
                return instance;
            },
        });
    }
}

// values 是 FileLoader/load 函数生成 target 对象
function getInstance(values, ctx) {
  //上文 FileLoader 里实现中我们讲过,target 对象是一个由路径和 exports 组装成的一个大对象,这里 Class 是为了确定其是不是一个 exports ,有可能是一个路径名
  const Class = values[EXPORTS] ? values : null;
  let instance;
  if (Class) {
    if (is.class(Class)) {
        //这一步很重要,如果是类,就用 ctx 进行初始化获取实例
        instance = new Class(ctx);
    } else {
        //普通对象直接导出,这里要注意的是如果是 exports 函数,在 FileLoader 实现中已经将其执行并转换为了对象
        // function 和 class 分别在子类和父类的处理的原因是, function 的处理逻辑 loadMiddleware,loadService,loadController 公用,而 class 的处理逻辑 loadService 使用
        instance = Class;
    }
  } else if (is.primitive(values)) {
       //原生类型直接导出
       instance = values;
  } else {
       //如果目前的 target 部分是一个路径,那么会新建一个 ClassLoader 实例,这个 ClassLoader 中又会递归的调用 getInstance
       //这里之所以新建一个类,一是为了做缓存,二是为了在每个节点获取到的都是一个类的实例
       instance = new ClassLoader({ ctx, properties: values });
  }
  return instance;
}

loadService 的实现

有了 ContextLoader 类,那实现 loadService 函数就非常容易了,如下:

// egg-core -> loadService 函数实现源码
// loadService 函数调用 loadToContext 函数
loadService(opt) {
    opt = Object.assign({
      call: true,
      caseStyle: 'lower',
      fieldClass: 'serviceClasses',
      directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')), //所有加载单元目录下的 service
    }, opt);
    const servicePaths = opt.directory;
    this.loadToContext(servicePaths, 'service', opt);
}
// loadToContext 函数直接新建 ContextLoader 实例,调用 load 函数实现加载
loadToContext(directory, property, opt) {
    opt = Object.assign({}, {
      directory,
      property,
      inject: this.app,
    }, opt);
    new ContextLoader(opt).load();
}

loadMiddleware 函数

中间件是 Koa 框架中很重要的一个环节,通过 app.use 引入中间件,使用洋葱圈模型,所以中间件加载的顺序很重要。 - 如果在上文中的 config 中配置的中间件,系统会自动用 app.use 函数使用该中间件 - 所有的中间件我们都可以在 app.middleware 中通过中间件 name 获取到,便于在业务中动态使用

// egg-core 源码 -> loadMiddleware 函数实现源码

loadMiddleware(opt) {
    const app = this.app;
    opt = Object.assign({
      call: false,   // call=false 表示如果中间件导出是函数,不会主动调用函数做转换
      override: true,
      caseStyle: 'lower',
      directory: this.getLoadUnits().map(unit => join(unit.path, 'app/middleware')) //所有加载单元目录下的 middleware
    }, opt);
    const middlewarePaths = opt.directory;
    //将所有中间件 middlewares 挂载在 app 上,这个函数在 loadController 实现中也用到了,看下文的实现
    this.loadToApp(middlewarePaths, 'middlewares', opt);
    //将 app.middlewares 中的每个中间件重新绑定在 app.middleware 上,每个中间件的属性不可配置,不可枚举
    for (const name in app.middlewares) {
      Object.defineProperty(app.middleware, name, {
        get() {
          return app.middlewares[name];
        },
        enumerable: false,
        configurable: false,
      });
    }
    //只有在 config 中配置了 appMiddleware 和 coreMiddleware 才会直接在 app.use 中使用,其它中间件只是挂载在 app 上,开发人员可以动态使用
    const middlewareNames = this.config.coreMiddleware.concat(this.config.appMiddleware);
    const middlewaresMap = new Map();
    for (const name of middlewareNames) {
      //如果 config 中定义 middleware 在 app.middlewares 中找不到或者重复定义,都会报错
      if (!app.middlewares[name]) {
        throw new TypeError(`Middleware ${name} not found`);
      }
      if (middlewaresMap.has(name)) {
        throw new TypeError(`Middleware ${name} redefined`);
      }
      middlewaresMap.set(name, true);
      const options = this.config[name] || {};
      let mw = app.middlewares[name];
      //中间件的文件定义必须 exports 一个普通 function ,并且接受两个参数:
      // options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来, app: 当前应用 Application 的实例
      //执行 exports 的函数,生成最终要的中间件
      mw = mw(options, app);
      mw._name = name;
      //包装中间件,最终转换成 async function(ctx, next) 形式
      mw = wrapMiddleware(mw, options);
      if (mw) {
        app.use(mw);
        this.options.logger.info('[egg:loader] Use middleware: %s', name);
      } else {
        this.options.logger.info('[egg:loader] Disable middleware: %s', name);
      }
    }
}

//通过 FileLoader 实例加载指定属性的所有文件并导出,然后将该属性挂载在 app 上
loadToApp(directory, property, opt) {
    const target = this.app[property] = {};
    opt = Object.assign({}, {
      directory,
      target,
      inject: this.app,
    }, opt);
    new FileLoader(opt).load();
}

loadController 函数

controller 中生成的函数最终还是在 router.js 中当作一个中间件使用,所以我们需要将 controller 中内容转换为中间件形式 async function(ctx, next) ,其中 initializer 这个函数就是用来针对不同的情况将 controller 中的内容转换为中间件的,下面是 loadController 的实现逻辑:

// egg-core源码 -> loadController 函数实现源码

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) && !is.asyncFunction(obj)) {
          obj = obj(this.app);
        }
        if (is.class(obj)) {
          obj.prototype.pathName = opt.pathName;
          obj.prototype.fullPath = opt.path;
          //如果是一个 class,class 中的函数转换成 async function(ctx, next) 中间件形式,并用 ctx 去初始化该 class ,所以在 controller 里我们也可以使用 this.ctx.xxx 形式
          return wrapClass(obj);
        }
        if (is.object(obj)) {
          //如果是一个 Object ,会递归的将该 Object 中每个属性对应的函数转换成 async function(ctx, next) 中间件形式形式
          return wrapObject(obj, opt.path);
        }
        if (is.generatorFunction(obj) || is.asyncFunction(obj)) {
          return wrapObject({ 'module.exports': obj }, opt.path)['module.exports'];
        }
        return obj;
      },
    }, opt);
    // loadController 函数同样是通过 loadToApp 函数将其导出对象挂载在 app 下,controller 里的内容在 loadRouter 时会将其载入
    const controllerBase = opt.directory;
    this.loadToApp(controllerBase, 'controller', opt);
  },

loadRouter 函数

loadRouter 函数特别简单,只是 require 加载一下 app/router 目录下的文件而已,而所有的事情都交给了 EggCore 类上的 router 属性去实现

而 router 又是 Router 类的实例,Router 类是基于 koa-router 实现的

// egg-core 源码 -> loadRouter 函数源码实现

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

//设置 router 属性的 get 方法
get router() {
    //缓存设置
    if (this[ROUTER]) {
      return this[ROUTER];
    }
    //新建 Router 实例,其中 Router 类是继承 koa-router 实现的
    const router = this[ROUTER] = new Router({ sensitive: true }, this);
    //在启动前将 router 中间件载入引用
    this.beforeStart(() => {
      this.use(router.middleware());
    });
    return router;
}

//将 router 上所有的 method 函数代理到 EggCore 上,这样我们就可以通过 app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1') 的方式配置路由
utils.methods.concat([ 'all', 'resources', 'register', 'redirect' ]).forEach(method => {
  EggCore.prototype[method] = function(...args) {
    this.router[method](...args);
    return this;
  };
})

Router 类继承了 KoaRouter 类,并对其的 method 相关函数做了扩展,解析 controller 的写法,同时提供了 resources 方法,为了兼容 restAPI 的请求方式

关于 restAPI 的使用方式和实现源码我们这里就不介绍了,可以看官方文档,有具体的格式要求,下面看一下 Router 类的部分实现逻辑:

// egg-core源码 -> Router 类实现源码

class Router extends KoaRouter {
  constructor(opts, app) {
    super(opts);
    this.app = app;
    //对 method 方法进行扩展
    this.patchRouterMethod();
  }

  patchRouterMethod() {
    //为了支持 generator 函数类型,以及获取 controller 类中导出的中间件
    methods.concat([ 'all' ]).forEach(method => {
      this[method] = (...args) => {
        // spliteAndResolveRouterParams 主要是为了拆分 router.js 中的路由规则,将其拆分成普通中间件和 controller 生成的中间件部分,请看下文源码
        const splited = spliteAndResolveRouterParams({ args, app: this.app });
        args = splited.prefix.concat(splited.middlewares);
        return super[method](...args);
      };
    });
  }

  //返回 router 里每个路由规则的前缀和中间件部分
  function spliteAndResolveRouterParams({ args, app }) {
    let prefix;
    let middlewares;
    if (args.length >= 3 && (is.string(args[1]) || is.regExp(args[1]))) {
        // app.get(name, url, [...middleware], controller) 的形式
        prefix = args.slice(0, 2);
        middlewares = args.slice(2);
      } else {
        // app.get(url, [...middleware], controller) 的形式
        prefix = args.slice(0, 1);
        middlewares = args.slice(1);
      }
      // controller 部分肯定是最后一个
      const controller = middlewares.pop();
      // resolveController 函数主要是为了处理 router.js 中关于 controller 的两种写法:
      //写法 1 :app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1')
      //写法 2 :app.get('/async', ...asyncMiddlewares, subController.subHome.async1)
      //最终从 app.controller 上获取到真正的 controller 中间件,resolveController 具体函数实现就不介绍了
      middlewares.push(resolveController(controller, app));
      return { prefix, middlewares };
  }

总结

以上便是我对 egg-core 的大部分源码的实现的学习总结,其中关于源码中一些 debug 代码以及 timing 运行时间记录的代码都删掉了,关于 app 的生命周期管理的那部分代码和 loadUnits 加载逻辑关系不大,所以没有讲到。EggCore 的核心在于 EggLoader,也就是 plugin,config, extend, service, middleware, controller, router 的加载函数,而这几个内容加载必须按照顺序进行加载,存在依赖关系,比如:

  • 加载 middleware 时会用到 config 关于应用中间件的配置
  • 加载 router 时会用到关于 controller 的配置
  • 而 config,extend,service,middleware,controller 的加载都必须依赖于 plugin,通过 plugin 配置获取插件目录
  • service,middleware,controller,router 的加载又必须依赖于 extend(对 app 进行扩展),因为如果 exports 是函数的情况下,会将 app 作为参数执行函数

EggCore 是一个基础框架,其最重要的是需要遵循一定的约束和约定,可以保证一致的代码风格,而且提供了插件和框架机制,能使相同的业务逻辑实现复用,后面看有时间再写一下 Egg 框架的源码学习心得

参考文献