前言
Egg是一个企业级应用框架,它奉行 「 约定优于配置 」,按照一套统一约定进行应用开发。Egg 不直接提供功能,只是集成各种功能插件。Egg 的插件机制有很高的可扩展性,一个插件只做一件事(比如 Nunjucks 模板封装成了 egg-view-nunjucks、MySQL 数据库封装成了 egg-mysql)。Egg 通过框架聚合这些插件,并根据自己的业务场景定制配置,这样应用的开发成本就变得很低。
egg 目录结构
我们来看一下 Egg 的目录结构
egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
| ├── router.js
│ ├── controller
│ | └── home.js
│ ├── service (可选)
│ | └── user.js
│ ├── middleware (可选)
│ | └── response_time.js
│ ├── schedule (可选)
│ | └── my_task.js
│ ├── public (可选)
│ | └── reset.css
│ ├── view (可选)
│ | └── home.tpl
│ └── extend (可选)
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config
| ├── plugin.js
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可选)
| ├── config.local.js (可选)
| └── config.unittest.js (可选)
└── test
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js
如上,框架约定的目录:
- app/router.js 用于配置 URL 路由规则
- app/controller/** 用于解析用户的输入,处理后返回响应的结果
- app/service/** 用于编写业务逻辑层,该目录可选
- app/middleware/** 用于编写中间件,该目录可选
- app/public/** 用于放置静态资源,该目录可选
- app/extend/** 用于框架的扩展,该目录可选
- config/config.{env}.js 用于编写配置文件
- config/plugin.js 用于配置需要加载的插件
- test/** 用于单元测试
- app.js 和 agent.js 用于自定义启动时的初始化工作,可选
在团队开发中,按照约定的目录规范进行开发,可以帮助开发团队和开发人员降低开发和维护成本。
简易版 Egg 实现
我们参照 Egg 的目录规范,实现一个简易版的 Egg 框架。我们的框架选择 koa 为基础框架。
框架目录
- index.js 项目入口文件,实例化 Egg 类并启动服务
- egg.js 挂载 koa 实例、路由、controller、service、config 等,定义 start 方法
- loader.js 解析 config、controller、middleware、model、routes、schedule、service
- config/** 用于编写配置文件
- controller/** 用于解析用户输入
- middleware/** 用于编写中间件
- model/** 用于编写数据层代码
- routes/** 用于配置 URL 路由规则
- schedule/** 用于编写定时任务
- service/** 用于编写业务逻辑层
入口文件
入口文件:index.js
在 index.js 中,我们期望实现的功能是:初始化一个 app 实例,然后调用 app 实例的 start 方法来启动服务。
代码实现如下:
// 初始化一个 app 实例,并启动
const egg = require('./egg');
// 初始化 app 实例
const app = new egg();
// 启动服务
app.start(3000);
接下来我们要实现的是 egg.js 文件
Egg 类
Egg 类:egg.js
在 egg.js 中,实现一个 Egg 类,在 Egg 类的 constructor 构造函数中挂载 koa 实例、路由、controller、service、config 等。并在 Egg 类中定义 start 方法,调用 koa 实例来启动服务器。代码如下:
const koa = require('koa')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
框架的关键实现在于目录和文件的解析。接下来,我们对约定的目录进行解析。对目录的解析代码我们统一放在 loader.js 文件中
目录解析:loader.js
读取目录
我们先来实现一个读取目录的方法,这个方法在后面的目录解析中都会调用到。
load 方法接收两个参数,dir 为目录名称;cb 为回调函数,业务上的具体处理都会在 cb 中处理。在 load 方法中,使用 fs.readdirSync 方法读取目录的内容,然后遍历读取到的目录内容,将文件名和文件内容在 cb 中返回出去。
// 读取目录
function load(dir, cb) {
// 获取绝对路径
const url = path.resolve(__dirname, dir)
// 同步的读取目录的内容
const files = fs.readdirSync(url)
// 遍历
files.forEach(filename => {
// 去掉后缀,提取文件名
filename = filename.replace('.js', '')
// 导入文件
const file = require(url + '/' + filename)
// 将文件名和文件内容返回出去
cb(filename, file)
})
}
routes 目录解析
routers 目录存放的是路由文件,比如在 routers 目录有定义了 user.js 文件,在该文件中配置的就是 user 的路由,如配置了路由 get /info
,则访问时的路径为 /user/info
。
// 初始化路由
function initRouter(app) {
const router = new Router()
// 调用 load 方法,读取 routers 目录,对目录下的文件内容初始化成路由
load('routes', (filename, routes) => {
// index前缀处理
const prefix = filename === 'index' ? '' : `/${filename}`
// 路由类型判断
routes = typeof routes === 'function' ? routes(app) : routes
// 遍历添加路由
Object.keys(routes).forEach(key => {
const [method, path] = key.split(' ')
let routerPath = `${prefix}${path}`
if (/^\/.+\/$/.test(routerPath)) routerPath = routerPath.substr(0, routerPath.length - 1)
console.log(`正在映射地址 ${method.toLocaleUpperCase()} routerPath`)
// 注册
// router[method](prefix + path, routes[key])
router[method](routerPath, async ctx => {
app.ctx = ctx
await routes[key](app)
})
})
})
return router
}
在 initRouter 方法中,将将 routers 目录下的文件批量注册成路由,省去了我们手动一个一个的注册路由的麻烦。
我们从路由文件中定义的路由内容提取出路由path和路由对应的处理方法,将他们注册成如下的形式:
// koa-router 路由注册
router.get('/', async (ctx, next) => {
console.log('index');
ctx.body = 'index';
});
如我们在 user 路由文件中定义了如下的路由:
"get /info": app => {
app.ctx.body = "用户年龄" + app.$service.user.getAge();
}
initRouter 方法会帮我们注册成:
router.get('/user/info', app => {
app.ctx.body = "用户年龄" + app.$service.user.getAge();
});
initRouter 方法定义好后,在 loader.js 文件中导出:
module.exports = { initRouter }
然后在 egg.js 文件中导入 initRouter 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$router = initRouter(this)
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
controller 目录解析
controller 目录存放的文件主要是对用户的请求参数进行处理(校验,转换),然后调用对应的 service 方法处理业务,得的业务结果后将其返回。
function initController(app) {
const controllers = {}
// 读取目录
// load 方法的第一个参数为 目录名称
// load 方法的第二个参数为一个回调函数,参数 filename 为文件名称,controller 为在文件中定义的业务处理方法
load('controller', (filename, controller) => {
// 将文件名称与文件中定义的方法映射成 key/value 的形式
// 便于在定义路由的时候,可以通过 ${fileName}.${functionName} 的方式指定对应的 Controller
controllers[filename] = controller(app)
})
return controllers
}
initController 方法解析 controller 目录下的文件,将文件名称与文件中定义的 function 映射成 key/value 的形式,便于在定义路由的时候,可以通过 ${fileName}.${functionName} 的方式指定对应的 Controller。
在 controller 目录下有一个 home.js 文件,在 home.js 文件中定义了一个 detail 的处理函数,如下:
module.exports = app => ({
detail: ctx => {
app.ctx.body = '详细页面内容'
}
})
在路由文件中调用 detail 处理函数:
module.exports = app => ({
'get /detail': app.$controller.home.detail
})
在 app.controller.home.detail
中,app 为我们的 egg 实例,$controller 代表 controller 目录的名称,home 代表 controller 目录下的home.js 文件,detail 就是我们在 home.js 文件中定义的处理函数。
initController 方法定义好后,在 loader.js 文件中导出:
module.exports = { initController }
然后在 egg.js 文件中导入 initController 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$controller = initController(this) // 加载ctrl
this.$router = initRouter(this)
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
service 目录解析
service 目录下的文件用于对复杂数据和第三方服务的调用。
- 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回给用户显示。或者计算完成后,更新到数据库。
- 第三方服务的调用,比如 GitHub 信息获取等。
initService 方法解析 service 目录下的文件,将文件名称与文件中定义的 function 映射成 key/value 的形式,可以在 controller 中通过 ${fileName}.${functionName} 的方式指定对应的 service。// service 目录解析
function initService() {
const services = {}
// 将文件名称与文件中定义的方法映射成 key/value 的形式
// 可以通过 ${fileName}.${functionName} 的方式指定对应的 Service
load('service', (filename, service) => {
services[filename] = service
})
return services
}
比如在 service 目录下有一个 user.js 文件,在 user.js 文件中定义了一个 getName 的处理函数,如下:
module.exports = {
getName() {
return 'Tom'
}
}
在 controller 中调用 getName 方法:
module.exports = app => ({
index: async ctx => {
ctx.body = '首页Ctrl'
const name = await app.$service.user.getName()
pp.ctx.body = 'ctrl user' + name
}
})
在 app.$service.user.getName()
中,app 为我们的 egg 实例,$service 代表 service 目录的名称,user 代表 service 目录下的 user.js 文件,getName 就是我们在 user.js 文件中定义的处理函数。
initService 方法定义好后,在 loader.js 文件中导出:
module.exports = { initService }
然后在 egg.js 文件中导入 initService 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController, initService } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$service = initService() // 加载 service
this.$controller = initController(this) // 加载 controller
this.$router = initRouter(this) // 加载路由
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
config 目录解析
config 目录用于编写配置文件。config 目录下的文件将会被自动合并,合并后的配置可以直接成 app.$config 获取。
function loadConfig(app) {
let config = {}
load('config', (filename, conf) => {
// 合并 config 目录下的配置
config = Object.assign(config, conf)
})
return config
}
loadConfig 方法定义好后,在 loader.js 文件中导出:
module.exports = { loadConfig }
然后在 egg.js 文件中导入 loadConfig 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController, initService, loadConfig } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$config = loadConfig(this) 加载 config
this.$service = initService() // 加载 service
this.$controller = initController(this) // 加载 controller
this.$router = initRouter(this) // 加载路由
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
model 目录解析
model 目录下的文件是用来和数据库打交道的。在框架中,我们使用 sequelize 框架来帮助我们管理数据层的代码。sequelize 是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。
const Sequelize = require('sequelize')
function initModel(app) {
const conf = app.$config
if (conf.db) {
app.$db = new Sequelize(conf.db)
// 加载模型
app.$model = {}
load('model', (filename, { schema, options }) => {
// 将模型映射成 key/value 的形式,挂载到 app 实例上
app.$model[filename] = app.$db.define(filename, schema, options)
})
app.$db.sync()
}
}
initModel 方法解析 目录下的文件,将文件名称与文件中定义的 Sequelize 模型 映射成 key/value 的形式,就可以通过 ${fileName}.${functionName} 的方式调用对应的模型方法了。
比如在 model 目录下定义了一个 User 的模型,也就是在数据库中建一个 user 表,
const { STRING } = require("sequelize");
module.exports = {
schema: {
name: STRING(30)
},
options: {
timestamps: false
}
};
使用 initModel 方法解析后,我们可以通过 ${fileName}.${functionName} 的方式调用对应的模型方法:
module.exports = app => ({
index: async ctx => {
// 调用 User 模型的 findAll() 方法,查找 user 表中的所有用户
app.ctx.body = await app.$model.user.findAll()
}
})
initModel 方法定义好后,在 loader.js 文件中导出:
module.exports = { initModel }
然后在 egg.js 文件中导入 loadConfig 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController, initService, loadConfig, initModel } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$config = loadConfig(this) 加载 config
initModel(app) // 初始化 model
this.$service = initService() // 加载 service
this.$controller = initController(this) // 加载 controller
this.$router = initRouter(this) // 加载路由
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
middleware目录解析
middleware 目录用于编写中间件函数,然后在 config 文件中应用写好的中间件函数
function initMiddleware(app) {
// 从 egg 实例上获取配置
const conf = app.$config;
// 读取配置中的 middleware 属性
if (conf.middleware) {
// 通过 koa 实例使用中间件
conf.middleware.forEach(mid => {
const midPath = path.resolve(__dirname, 'middleware', mid)
// $app 为 koa 实例
app.$app.use(require(midPath))
})
}
}
在 initMiddleware 函数中,首先从我们的 egg 实例上获取配置,然后读取在配置中配置使用的中间件,并使用 koa 实例来加载中间件。
比如我们再 middleware 目录定义了一个 logger 的中间件函数:
module.exports = async (ctx, next) => {
console.log(ctx.method + " " + ctx.path);
const start = new Date();
await next();
const duration = new Date() - start;
console.log(
ctx.method + " " + ctx.path + " " + ctx.status + " " + duration + "ms"
);
};
在config文件中配置使用 logger 中间件:
module.exports = {
middleware: ['logger']
}
经过 initMiddleware 的解析,就可以使用我们编写的中间件函数了。
initMiddleware 方法定义好后,在 loader.js 文件中导出:
module.exports = { initMiddleware }
然后在 egg.js 文件中导入 loadConfig 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController, initService, loadConfig, initMiddleware } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$config = loadConfig(this) 加载 config
initModel(app) // 初始化 model
initMiddleware(app) // 初始化中间件
this.$service = initService() // 加载 service
this.$controller = initController(this) // 加载 controller
this.$router = initRouter(this) // 加载路由
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
schedule 目录解析
schedule 目录用于编写定时任务的文件。如我们需要定时爬取某个站点上的文章,我们就可以编写一个爬虫文件存放在 schedule 目录下。
const schedule = require('node-schedule')
function initSchedule(app) {
const conf = app.$config;
// 加载 schedule
load('schedule', (filename, scheduleConfig) => {
if (conf.schedule) {
// 根据配置的 schedule 来启动指定的定时任务
conf.schedule.forEach(job => {
if (filename === job) {
// schedule.scheduleJob() 创建一个定时任务
schedule.scheduleJob(scheduleConfig.interval, scheduleConfig.handler)
}
})
}
})
}
在 initSchedule 喊数中,调用 load 方法加载 schedule 目录下的文件,然后根据用户的配置来启动对应的定时任务。使用 node-schedule 的 scheduleJob() 方法来创建一个定时任务。
initSchedule 方法定义好后,在 loader.js 文件中导出:
module.exports = { initSchedule }
然后在 egg.js 文件中导入 loadConfig 方法,并将其挂载到 egg 实例上,egg.js 文件变成如下:
const koa = require('koa')
const { initRouter, initController, initService, loadConfig, initMiddleware, initSchedule } = require('./kkb-loader')
class Egg {
constructor(conf) {
// 初始化 koa 实例,挂载到 我们的 egg 实例上
this.$app = new koa(conf)
// 其它实例方法挂载
this.$config = loadConfig(this) 加载 config
initMiddleware(app) // 初始化中间件
initModel(app) // 初始化 model
this.$service = initService() // 加载 service
this.$controller = initController(this) // 加载 controller
this.$router = initRouter(this) // 加载路由
initSchedule(this) // 初始化定时任务
}
// 启动服务器
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
完整代码
index.js
const egg = require('./egg')
const app = new egg()
app.start(7001)
egg.js
const koa = require('koa')
const { initRouter,
initController,
initService,
loadConfig,
initMiddleware,
initModel,
initSchedule
} = require('./loader')
class Egg {
constructor(conf) {
this.$app = new koa(conf)
// loadConfig(this)
this.$config = loadConfig(this)
initMiddleware(this)
initModel(this)
this.$service = initService()
this.$ctrl = initController(this) // 加载ctrl
this.$router = initRouter(this)
this.$app.use(this.$router.routes())
initSchedule(this)
}
start(port) {
this.$app.listen(port, () => {
console.log('服务器启动 端口:' + port)
})
}
}
module.exports = Egg
loader.js
const fs = require('fs')
const path = require('path')
const Router = require('koa-router')
// 读取目录
function load(dir, cb) {
// 获取绝对路径
const url = path.resolve(__dirname, dir)
const files = fs.readdirSync(url)
// 遍历
files.forEach(filename => {
// 去掉后缀
filename = filename.replace('.js', '')
// 导入文件
const file = require(url + '/' + filename)
cb(filename, file)
})
}
function initRouter(app) {
const router = new Router()
load('routes', (filename, routes) => {
// index前缀处理
const prefix = filename === 'index' ? '' : `/${filename}`
// 路由类型判断
routes = typeof routes === 'function' ? routes(app) : routes
// 遍历添加路由
Object.keys(routes).forEach(key => {
const [method, path] = key.split(' ')
let routerPath = `${prefix}${path}`
if (/^\/.+\/$/.test(routerPath)) routerPath = routerPath.substr(0, routerPath.length - 1)
console.log(`正在映射地址 ${method.toLocaleUpperCase()} ${routerPath}`)
// 注册
// router[method](prefix + path, routes[key])
router[method](routerPath, async ctx => {
app.ctx = ctx
await routes[key](app)
})
})
})
return router
}
function initController(app) {
const controllers = {}
// 读取目录
load('controller', (filename, controller) => {
controllers[filename] = controller(app)
})
return controllers
}
function initService() {
const services = {}
load('service', (filename, service) => {
services[filename] = service
})
return services
}
function loadConfig(app) {
let config = {}
load('config', (filename, conf) => {
config = Object.assign(config, conf)
})
return config
}
const Sequelize = require('sequelize')
function initModel(app) {
const conf = app.$config
if (conf.db) {
app.$db = new Sequelize(conf.db)
// 加载模型
app.$model = {}
load('model', (filename, { schema, options }) => {
app.$model[filename] = app.$db.define(filename, schema, options)
})
app.$db.sync()
}
}
function initMiddleware(app) {
const conf = app.$config;
if (conf.middleware) {
conf.middleware.forEach(mid => {
const midPath = path.resolve(__dirname, 'middleware', mid)
// $app 为 koa 实例
app.$app.use(require(midPath))
})
}
}
const schedule = require('node-schedule')
function initSchedule(app) {
const conf = app.$config;
load('schedule', (filename, scheduleConfig) => {
if (conf.schedule) {
conf.schedule.forEach(job => {
if (filename === job) {
schedule.scheduleJob(scheduleConfig.interval, scheduleConfig.handler)
}
})
}
})
}
module.exports = {
initRouter,
initController,
initService,
loadConfig,
initMiddleware,
initModel,
initSchedule
}