Egg-core 解读

375 阅读7分钟

Egg介绍

egg 是阿里出品的企业级后台开发框架。它是以 koa 作为其基础框架,在它的模型基础之上进一步对它进行了一些增强。添加了一些拓展功能(校验、日志、curl、cookie/session等)及加载器(Loader ),同时也引入了插件的概念。egg 插件不同于 koa 本身的中间件机制,它支持按照特定的顺序以及依赖关系去启动。 egg 奉行约定优于配置的方式,按照一套统一的约定规则进行应用开发。用这种方式可以减少开发人员的学习成本,不需要在开发时考虑结构或目录的分类方式。其主要体现就是按照功能差异将代码放到不同的目录下管理,并通过加载器实现了这套约定。

约定的目录

// 基础的目录结构
egg-project
├── package.json
└── app
    ├── router.js
    ├── controller
    │   ├── home.js
    │   └── other.js
    └── service
        └── home.js   

如上,由框架约定的目录:

  • app/router.js 用于配置 URL 路由规则
  • app/controller/** 用于解析用户的输入,处理后返回相应的结果
  • app/service/** 用于编写业务逻辑层

注:没有常见的 index/app/main.js 等文件,而是把启动逻辑放到 egg-bin 去执行

router

主要用来描述请求方式及 URL 和具体承担执行动作的 Controller 三者的对应关系

// app/router.js
module.exports = (app) => {
  const { router, controller } = app
  router.get('/home', controller.home.list)
}

controller

主要对请求入参/出参进行处理(入参校验、结果转换等),然后调用对应的 service 处理业务,得到结果后封装并返回

// app/controller/home.js
const Controller = require('egg').Controller;
class HomeControler extends Controller {
  async list() {
    const { ctx, service } = this
    // 入参校验
    this.ctx.validate({
      title: { type: 'string' },
      content: { type: 'string' },
    }, ctx.request.body);
    // 组装参数
    const req = Object.assign(ctx.request.body, { author });
  	// 调用 Service 进行业务处理
    const res = await service.home.getList(req)
    const res2 = await service.home.getOther(req)
  	// 设置响应内容
    this.ctx.body = { code: 200, data: { list: res, info: res2 } }
  }
}
module.exports = HomeControler

service

用于在复杂业务场景下将业务逻辑封装的一个抽象层。它确保 Controller 中的逻辑更加简洁,以及保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用

// app/service/home.js
const Service = require('egg').Service;
class HomeService extends Service {
  async getList(params) {
    const { uid } = params
    const list = await this.ctx.db.query(
      'select * from home where uid = ?',
      uid,
    );
    return list
  }

  async getOther() {
    return { name: 'cc' }
  }
}
module.exports = HomeService

加载器(Loader)

egg-core 核心内容就是实现了加载器,并且使用它将各个模块按照一定的顺序执行,并将它们之间关联起来的程序。加载器会按如下文件顺序加载,每个文件或目录(由上到下)

  • 加载 service,加载应用的 app/service 目录。对应 loadService 加载器
  • 加载 controller,加载应用的 app/controller 目录。对应 loadController 加载器
  • 加载 router,加载应用的 app/router.js 文件。对应 loadRouter 加载器

loadRouter

  • 读取文件、注入上下文对象(ctx)、挂载路由节点等
loadRouter() {
  const filePath = path.join(this.options.baseDir, 'app/router')
  // app 既是 Application 实例 -> app = new Koa()
  const inject = this.app
  // 使用 require 按照路径加载,并执行 (app) => {}
  let exec = require(filePath)
  if (typeof exec === 'function') {
    exec = exec(inject)
  	// 执行普通的中间件加载,及路由挂载方式
    this.app.use(this.app.router.routes())
      .use(this.app.router.allowedMethods())
  }
  return exec
}

loadController

  • 创建 app.controller 对象用于挂载目录下所有的 controller 及其中的方法
    • 相当于对 app 对象做了拓展
  • 读取类内所有的 public 方法,并做函数包裹成为路由中间件的方式
    • 路由事例:router.get('/home', (ctx) => {})
loadController() {
  const dirPath = path.join(this.options.baseDir, 'app/controller')
  // 导出的 controller 类内 函数 的挂载点
  const target = this.app.controller = {}
  // 读取目录下所有文件的完整路径,并执行回调 callback
  this.loadClass(dirPath, ({ name, fullPath }) => {
    // name: home.js -> home
    // fullPath: app/controller/home.js
    target[name] = getExports(fullPath, {
      initializer: (obj) => {
        // obj -> class HomeControler{}
        return wrapControllerClass(obj)
      }
    })
  })
}

// 读取目录下所有文件的完整路径,并执行相关回调
loadClass(dirPath, callback) {
  // files -> [ { name, fullPath } ]
  const files = loadDirectory(dirPath)
  for (const file of files) {
    callback(file)
  }
}

// 获取导出对象
function getExports(fullPath, { initializer } = {}) {
	// require 为 node 本身用于模块加载的
  // _exports 就是 module.exports = HomeControler -> class HomeControler{}
  let _exports = require(fullPath)
  if (initializer) {
    _exports = initializer(_exports)
  }
  return _exports
}

wrapControllerClass 及 methodToMiddleware 函数

  • 使用wrapControllerClass函数,获取类内所有公共方法进行包裹
    • 通过原型(prototype)获取所有方法
    • 使用methodToMiddleware函数,将类内的方法进行中间件化
function wrapControllerClass(Controller) {
  const ret = {}
  // 获取 Controller 的原型
  const proto = Controller.prototype
  // 获取 ControllerClass 上所有方法名 -> [ list ]
  const keys = Object.getOwnPropertyNames(proto)
  // *由于 class 的方法是不可迭代的,所以不能直接通过 proto 去遍历
  for (const k of keys) {
    if (k === 'constructor') continue
    // 获取描述对象
    const d = Object.getOwnPropertyDescriptor(proto, k)
    if (typeof d.value === 'function' && !ret.hasOwnProperty(k)) {
      // 包裹成中间件形式
      ret[k] = methodToMiddleware(Controller, k)
    }
  }
  return ret
	
  function methodToMiddleware(Controller, key) {
    // args 为当前的 router -> [ctx, next]
    return function handle(...args) {
      // 实时的进行实例化类并把 ctx 塞进去
      const constroller = new Controller(args[0])
      const fn = constroller[key]
      return fn.call(constroller)
    }
  }
}

伪代码展示

// 挂载点
app.controller = {
  home: wrapControllerClass(HomeControler)
}
app.controller.home = {
  list: methodToMiddleware()
}
// 等价于下面
app.controller = {
  home: {
    list: function handle(ctx) {
      // 实时的进行实例化,确保每次拿到的上下文对象(ctx)是当前周期内的
      return new Controller(ctx).list()
    }
  },
  other: {}
}

// 路由匹配:router.get('/home', controller.home.list)
const { controller } = app
router.get('/home', function handle(ctx) {
  return new Controller(ctx).list()
})

loadService

  • 创建挂载点,挂载导出的 serviceClass 对象
    • 不同于上面的,是对 app.context 的拓展
  • 同时在上下文对象(ctx)上添加service属性
loadService() {
  const dirPath = path.join(this.options.baseDir, 'app/service')

  const target = {}
  this.loadClass(dirPath, ({ name, fullPath }) => {
    // { home: class HomeService{} }
    target[name] = getExports(fullPath)
  })
  // 在 app.context -> ctx 上定义 service 字段
  defineService({ inject: this.app, target })
}

defineService 函数

  • 使用Object.defineProperty()对上下文中ctx.service属性,进行读取动作拦截
    • 为什么要做动作拦截,并只在访问到时实时生成
      • 由于 defineService 函数是在启动时就执行,并且也不在中间件上。不能像 controllerClass 那样通过路由中间件拿到当前的 ctx 对象
      • ctx 是实时生成的,每次周期内都是不同的。使用这样的方式就能实时拿到
      • 这样的方式通用类似于添加了一个中间件,只是它更加的干净
  • 使用ClassLoader类去构建整个 serviceClass 的缓存对象
    • 创建时只是获取的引用关系,只有使用时才创建及缓存
/**
 * 在 app.context = ctx 上定义 service 字段
 * 那么在之后的 koa 周期内的 ctx 内就可以获取到
 *
 * *target 就是 app/service 目录下所有导出的 service 对象
 */
function defineService(options) {
  const { inject: app, target } = options
  // *使用 Object.defineProperty() 进行操作拦截,并获取当前的 this = ctx
  Object.defineProperty(app.context, 'service', {
    get() {
      // *第一次缓存
      // 对整个导出的 servies 进行缓冲
      if (!this.tempCache) {
        this.tempCache = new ClassLoader({ properties: target, ctx: this })
      }
      return this.tempCache
    }
  })
}

ClassLoader

  • 内部使用 Map 创建缓存节点,用于缓存实例化后具体的 serviceClass 对象
  • 使用Object.defineProperty()定义拦截动作,并缓存数据
    • 为什么要做动作拦截
      • 使用 getter 拦截实现只在需要时才创建实例,防止无效的消耗
      • 为了防止在一个运行周期内做不必要的重复初始化,对 serviceClass 进行缓存
      • 同时也让 serviceClass 内部能够访问到 ctx 对象并使用它上的相关属性/方法
class ClassLoader {
  constructor(options) {
    this._cache = new Map()
    this._ctx = options.ctx

    // props -> { home: class HomeService{}, ohter: class OtherService{} }
    const properties = options.properties
    for (const property in properties) {
      // value 等价于 app/service/home 目录的 class HomeService{}
      const value = properties[property]
      // 读取时实时创建实例
      // 如:this -> ctx.service.{ home, other }
      // this.home -> new HomeService(ctx) 等价于 app.context.service.home
      Object.defineProperty(this, property, {
        get() {
          // *第二次缓存
          // 并进行 serviceInstance 缓存
          // 确保在一个周期内,同一个 service 只初始化一次
          let instance = this._cache.get(property)
          if (!instance) {
            // getInstance() 去实例化这个 value
            instance = getInstance(value, this._ctx)
            this._cache.set(property, instance)
          }
          return instance
        }
      })
    }
  }
}

伪代码展示

// 读取 ctx.service 时,会触发 defineService 内定义的 getter 函数
app.context.service
定义 app.context.tempCache 并初始化 new ClassLoader()
传入 {
  properties: { home: class HomeService{} }
  ctx
}
定义内部的 Object.defineProperty() 拦截行为
ClassLoader{}.home
初始化 new HomeService()

// 最终结果
app.context.service = 
// 第一层拦截
{
  // 第二层拦截
  home: {
    getList(),
    getOther()
  }
}

// controller 内使用
const { service } = this
service.home.getList(...)

总结

egg 使用对应的加载器去完成模版目录内容的读取及处理,并且由于有了加载器的协助,在开发阶段只需按照约定的目录去书写相关内容。并不需要硬性的导入所需模块(require),只需在业务层使用时读取相关属性/方法即可。 同时由于整套加载器会始终贯穿上下文对象(ctx),实现了在任何模块内都可方便调用上下文对象上面的拓展功能。使得整体的灵活性更加的高,可复用能力提升。