众所周知,Egg.js是由阿里开源的一款基于Koa的Node框架,上手也很简单,我们今天就来实现一个“企业级”的Node框架

如果你没有用过Egg,那么推荐你先去看一下官网文档,或者看我之前的教程,右上角搜索egg关键字应该可以找到。尝试一下编写代码,掌握了使用方法对实现有很大的帮助

以下内容默认你已经掌握了egg的使用方法

路由

作为服务端框架,路由是最基本的功能,egg框架的理念是约定大于配置,即所有的控制器都放在controller目录下、服务层都放在service目录下……

虽然在egg中,路由是一个单独的文件,但是这里我们也一起放在一个文件夹中,也方便以后分模块维护

我们先来构思一下整体框架

src
├── routes # 路由
│   └── index.js
├── controller # 控制层
│   └── index.js
├── service # 服务层
│   └── index.js
├── model # 模型
│   └── user.js
├── config # 配置
│   └── index.js
├── fgg-loader.js # 加载器
├── fgg.js # fgg构造器
└── app.js # 程序入口

在布局好整体框架之后我们开始编码,首先我们来实现loader,loader里面有一个load函数,用来加载不同目录下的文件,接收两个参数,一个文件夹名,一个回调函数,通过调函数返回文件名以及文件内容

const fs = require('fs')
const path = require('path')

// 读取目录下的文件
function load(dir, cb) {
  // 绝对路径
  const url = path.resolve(__dirname, dir)
  // 读取文件
  const files = fs.readdirSync(url)
  // 遍历文件,解析到路由器
  files.forEach(fileName => {
    fileName = fileName.replace('.js', '')
    // 导入文件,加载路由文件中导出的routes
    const file = require(url + '/' + fileName)
    cb(fileName, file)
  })
}

有了load函数我们就用它来加载路由文件

const Router = require('koa-router')

function initRouter() {
  const router = new Router()
  load('routes', (fileName, routes) => {
    // 前缀。index下没有前缀,否则文件名为前缀
    const prefix = fileName === 'index' ? '' : '/' + fileName
    Object.keys(routes).forEach(key => {
      const [method, path] = key.split(' ')
      // 映射路由
      router[method](prefix + path, routes[key])
    })
  })
  return router
}

我们这里需要规定路由文件的编写方式如下

module.exports = {
  'get /': async ctx => {
    ctx.body = '首页'
  },
  'get /detail': async ctx => {
    ctx.body = '详情'
  }
}

然后我们来看过程

image-20210518214656581

通过load函数加载routes目录下的文件,然后遍历读取出来的文件,通过调用回调函数将文件名和文件内容进行处理,如果文件名是index则默认/,否则就是/filename,然后通过解析路由对象属性名来获得HTTPMethod和path,然后将处理路由的中间件绑定在koa-router的实例上,最后返回这个路由实例

路由加载器我们已经完成了,然后我们来编写fgg的构造函数,因为是基于Koa的框架所以在创建构造函数时需要借助Koa

const Koa = require('koa')

const { initRouter } = require('./fgg-loader')

class Fgg {
  constructor(conf) {
    this.$app = new Koa(conf)
    this.$router = initRouter()
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`启动成功,端口${port}`)
    })
  }
}
module.exports = Fgg

最后我们来编写一个入口程序测试一下

const Fgg = require('./fgg')
const app = new Fgg()
app.start(3000)

看下效果,完成

image-20210518220153212

控制器

在没有引入控制器之前我们的逻辑都放在了routes里面,显然这是不方便的,路由的功能应该只是引导至某一控制器,接下来我们来将控制器逻辑添加到fgg中

还是我们先定好使用规则,然后倒推实现。控制器依旧是约定在controller中,我们要将所有的controller目录中的控制器绑定到app实例的$controller

首先编写路由文件

module.exports = {
  index: async app => {
    app.ctx.body = '首页Ctrl'
  },
  detail: async app => {
    app.ctx.body = '详情Ctrl'
  }
}

这时我们上面的路由需要做一些改动,因为需要在路由中使用app实例寻找controller,所以我们需要将路由文件改成一个函数,返回值一个对象

module.exports = app => ({
  'get /': app.$controller.index.index,
  'get /detail': app.$controller.index.detail
})

然后修改initRouter函数,使它能够解析现在的路由文件,当然我们也可以兼容之前的

function initRouter(app) {
  const router = new Router()
  load('routes', (fileName, routes) => {
    // 前缀。index下没有前缀,否则文件名为前缀
    const prefix = fileName === 'index' ? '' : '/' + fileName
    // 判断路由文件类型,如果是一个函数就执行,将app传入
    routes = typeof routes === 'function' ? routes(app) : routes 
    Object.keys(routes).forEach(key => {
      const [method, path] = key.split(' ')
      // 映射路由
      router[method](prefix + path, async ctx => {
        app.ctx = ctx
        await routes[key](app) // controller需要接收app
      })
    })
  })
  return router
}

然后我们来编写initController函数,负责解析控制器,同样使用load函数,将解析出来的控制器对象返回

function initController() {
  const controllers = {}
  // 加载controller
  load('controller', (fileName, controller) => {
    controllers[fileName] = controller
  })
  return controllers
}

最后修改一下fgg的构造函数

const Koa = require('koa')

const { initRouter, initController } = require('./fgg-loader')

class Fgg {
  constructor(conf) {
    this.$app = new Koa(conf)
    this.$controller = initController()
    this.$router = initRouter(this) // 将app实例作为参数
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`启动成功,端口${port}`)
    })
  }
}
module.exports = Fgg

完事,来看效果

image-20210518223231018

服务层

虽然有了控制层,我们可以将路由的压力分担到控制层,但是所有的逻辑都混在控制层后期维护起来也是一个很头疼的事情,所以我们再抽离出服务层用于查询数据,服务层的使用方法同路由基本一致,跟前面基本一致这里也不再废话了

一般查询数据库都是异步的,所以我们使用setTimeout+Promise先来模拟一下

// /service/index.js
const delay = (data, time) => new Promise(resolve => {
  setTimeout(() => {
    resolve(data)
  }, time)
})
module.exports = app => ({
  getDetail() {
    return delay('fgg: 企业级Node框架', 500)
  }
})
// controller/index.js
module.exports = {
  detail: async app => {
    app.ctx.body = await app.$service.index.getDetail()
  }
}

将service绑定在app.$servcie上,在controller调用service方法,然后又是熟悉的一步——解析service文件

function initService(app) {
  const services = {}
  load('service', (fileName, service) => {
    // 规定service必须是一个函数,接收app参数
    // (后面需要模型层调用model所以直接就设置成函数形式吧)
    services[fileName] = service(app)
  })
  return services
}

然后在fgg.js构造函数中添加service

const Koa = require('koa')

const { initRouter, initController, initService, loadConfig} = require('./fgg-loader')

class Fgg {
  constructor(conf) {
    this.$app = new Koa(conf)
    this.$service = initService(this)
    this.$controller = initController()
    this.$router = initRouter(this)
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`启动成功,端口${port}`)
    })
  }
}
module.exports = Fgg

再来测试一下

image-20210519230359700

模型层

最后就是与数据库的交互了,这里使用Sequelize做底层来实现ORM,需要安装sequelize依赖

首先在config配置文件中配置好数据库的信息

module.exports = {
  database: {
    dialect: 'mysql',
    host: 'localhost',
    database: 'example',
    username: 'root',
    password: '123456'
  },
}

在fgg-loader中加入创建sequelize实例的代码,仍然是约定好的,放在config目录下,如果存在数据库配置就初始化数据库

function loadConfig(app) {
  app.$model = {}
  load('config', (file, config) => {
    // 加载model
    if (config.database) {
      // 初始化数据库
      app.$database = new Sequelize(config.database)
      // 加载模型
      load('model', (fileName, { schema, options }) => {
        app.$model[fileName] = app.$database.define(fileName, schema, options)
      })
      app.$database.sync()
    }
  })
}

同时加载model目录下的模型,将定义好的模型挂载到app.$model上

在构造函数中添加配置解析

const Koa = require('koa')

const { initRouter, initController, initService, loadConfig} = require('./fgg-loader')

class Fgg {
  constructor(conf) {
    this.$app = new Koa(conf)
    loadConfig(this)
    this.$service = initService(this)
    this.$controller = initController()
    this.$router = initRouter(this)
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`启动成功,端口${port}`)
    })
  }
}
module.exports = Fgg

模型定义如下(Sequelize的模型定义方式)

const { STRING } = require('sequelize')

module.exports = {
  schema: {
    name: STRING
  },
  options: {
    timestamps: false
  }
}

然后就可以直接使用定义好的模型进行数据库查询

// controller
module.exports = {
  index: async app => {
    app.ctx.body = await app.$service.index.getUsers()
  },
}

// service
module.exports = app => ({
  async getUsers() {
    // app.$model.user.create({name: 'feng'})
    const res = await app.$model.user.findOne({
      where:
        { name: 'feng' }
    })
    return res
  }
})

齐活

image-20210519233254118

中间件

中间件的使用我们借助Koa的中间件机制,直接通过use使用中间件,就直接在config中一起加载,将中间件放在middleware属性下

module.exports = {
  db: {
    // ……
  },
  middleware: ['logger']
}

然后添加解析中间件的函数

function loadConfig(app) {
  app.$model = {}
  load('config', (file, config) => {
    // 加载model
    if (config.db) {
      // 初始化数据库
      app.$db = new Sequelize(config.db)
      // 加载模型
      load('model', (fileName, { schema, options }) => {
        app.$model[fileName] = app.$db.define(fileName, schema, options)
      })
      app.$db.sync()
    }
    // 加载中间件
    if (config.middleware) {
      config.middleware.forEach(mid => {
        const midPath = path.resolve(__dirname, "middleware", mid)
        app.$app.use(require(midPath))
      })
    }
  })
}

然后中间件的编写就跟我们以前写koa的中间件一样的

module.exports = async (ctx, next) => {
  const start = new Date()
  await next()
  const time = new Date() - start
  console.log(
    `[logger]${start.getHours()}:${start.getMinutes()}:${start.getSeconds()}: ${ctx.method}  ${ctx.path}  ${ctx.status}  ${time}ms`
  );
}

image-20210520233450827

一个简易的框架就搭好了


前端小白