从零开始实现简易版 egg 框架

1,359 阅读8分钟

前言

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.{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 信息获取等。
// service 目录解析
function initService() {
  const services = {}
  // 将文件名称与文件中定义的方法映射成 key/value 的形式
  // 可以通过 ${fileName}.${functionName} 的方式指定对应的 Service
  load('service', (filename, service) => {
    services[filename] = service
  })
  return services
}

initService 方法解析 service 目录下的文件,将文件名称与文件中定义的 function 映射成 key/value 的形式,可以在 controller 中通过 fileName.{fileName}.{functionName} 的方式指定对应的 service。

比如在 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.{fileName}.{functionName} 的方式调用对应的模型方法了。

比如在 model 目录下定义了一个 User 的模型,也就是在数据库中建一个 user 表,

const { STRING } = require("sequelize");
module.exports = {
  schema: {
    name: STRING(30)
  },
  options: {
    timestamps: false
  }
};

使用 initModel 方法解析后,我们可以通过 fileName.{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
}