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),实现了在任何模块内都可方便调用上下文对象上面的拓展功能。使得整体的灵活性更加的高,可复用能力提升。