前言
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 = ctxawait 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} 的方式指定对应的 Controllercontrollers[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) // 加载ctrlthis.$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} 的方式指定对应的 Serviceload('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() // 加载 servicethis.$controller = initController(this) // 加载 controllerthis.$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) 加载 configthis.$service = initService() // 加载 servicethis.$controller = initController(this) // 加载 controllerthis.$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.$configif (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) 加载 configinitModel(app) // 初始化 modelthis.$service = initService() // 加载 servicethis.$controller = initController(this) // 加载 controllerthis.$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) 加载 configinitModel(app) // 初始化 modelinitMiddleware(app) // 初始化中间件this.$service = initService() // 加载 servicethis.$controller = initController(this) // 加载 controllerthis.$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;// 加载 scheduleload('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) 加载 configinitMiddleware(app) // 初始化中间件initModel(app) // 初始化 modelthis.$service = initService() // 加载 servicethis.$controller = initController(this) // 加载 controllerthis.$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) // 加载ctrlthis.$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 = ctxawait 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.$configif (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}
