阅读 2053
用过egg.js之后,我决定基于koa给自己定制一个node框架--保姆级教程

用过egg.js之后,我决定基于koa给自己定制一个node框架--保姆级教程

从思考到动手其实只有一步之遥

用egg.js写了一个项目后,觉得这种约定大于定义的框架定义真的是...懒人的天堂,因为自己平时会写一些全栈型的项目,所以在思考egg的定义方式

代码参考了egg的设计思路,那么,不知道什么是egg.js的同学能写吗?当然可以!文章本身跟egg.js没有什么必然关系,不过感兴趣的同学还是可以摸摸的egg.js的,官网地址:eggjs.org/zh-cn/intro…

什么叫约定大于定义?

我们想这么一个场景,当我们使用koa进行后端开发的时候,你的控制层(controller)、模版层(model)、路由层(router)、服务层(server)这些层面的代码之间的文件命名方式、引入方式是不是一个仁者见仁智者见智的问题,也就是多人协作开发项目的时候经常会出现命名不规范、不统一出现的小尴尬,egg.js源码帮我们干好了一系列的操作,如:文件的统一引入、文件||文件夹的统一命名、等一系列的操作,真正做到了让用户可以开箱即用,所以约定就是框架本身给你约定成规矩的东西,定死的东西越多,你用的时候就可以越轻松

用koa写代码的时候

用koa起一个基本的例子 ---- 从这里这就开始了哈

  1. 创建一个myMvc文件夹
  2. npm init -y
  3. npm install koa koa-router
  4. 在myMvc下创建index.js作为入口文件
  5. 在myMvc下创建routes文件夹里面新建index.js

目录结构:

image.png

然后我们就开始写代码了

// 根index.js下
const Koa = require('koa');          
const router = require('koa-router')
const app = new Koa()
app.use(router.routes())
app.listen(3000)
复制代码
// routes > index.js
const router = require('koa-router')

router.get('/', (ctx) => {
  ctx.body = {
    data: 'hello world'
  }
})

export default router
复制代码

在根目录下用终端运行 node index.js 就可以看到我们启动的web服务了,浏览器打开http://localhost:3000 就能看到后端返回的数据了。这是最基础的koa的使用,接下来我们就基于这个写法进阶一波,在 routes > index.js 我需要引入const router = require('koa-router'),将来在 routes 下的所有的文件中我都要写上这行代码来实现引入路由的操作,懒如我便觉得,好累呀,能不能我不动你自己动!秉承着这个想法我希望把 routes > index.js 改写的尽可能简单一些

定义框架的一步又一步

一个优雅的框架被创建出来其最初始都是源自于程序员的一个小小的期许,我希望我的路由配置文件可以写成这样:

// routes > index.js 中的代码修改成这样
module.exports = {
  'get /': async ctx => {
    ctx.body = '首页'
  },
  'get /detail': async ctx => {
    ctx.body = '详情页'
  }
}
复制代码

诶!(好舒服)!当然现在写成这样纯属耍流氓(跑不起来项目),没有引入 router,只抛出了一个对象,那能不能让这个写法合法化呢?

起锅烧油!!!

1. 实现文件的自动引入

根目录下创建一个 wn-loader.js 文件

// 根目录 > wn-loader.js
const fs = require('fs')
const path = require('path')
const Router = require('koa-router')

// 读取目录和文件
function load(dir, cb) { // dir = routes
  // 获取绝对路径
  const url = path.resolve(__dirname, dir)
  // 读取目录
  const files = fs.readdirSync(url)
  // 遍历
  files.forEach(filename => {
    // index.js 去除扩展名
    filename = filename.replace('.js', '')
    const file = require(url + '/' + filename)
    cb(filename, file)
  })
}

// 测试
load('routes', filename => {
  console.log('routes:' + filename); 
})

// 打印结果:
// index {
//  'get /': [AsyncFunction: get /],
//  'get /detail': [AsyncFunction: get /detail]
// }
复制代码

定义一个load工具函数,接受文件夹名称(字符串)作为第一个参数,回调函数接收 文件名称 和 文件中的内容作为参数。这样我们就能同时拿到 文件名 和 文件中的内容

2. 加载路由

接下来让 routes > index.js中的写法合理化,我们是基于koa做的二次开发,所以路由文件中的内容要合理化,依然需要 koa 的路由注册 app.get('/', ctx => {}) 这种方式, 于是我们需要一个加载路由的函数

// 根目录 > wn-loader.js

... (部分代码省略)

// 加载路由
function initRouter() {
  const router = new Router()
  load('routes', (filename, routes) => {
    // 除了index文件,其他所有的文件中的内容都以文件名来设置路由前缀
    const prefix = filename === 'index' ? '' : `/${filename}`
    // routes 就是上一次打印结果中的对象
    Object.keys(routes).forEach(key => {
      const [method, path] = key.split(' ')  // ['get', '/']
      console.log(`正在映射地址: ${method.toLocaleUpperCase()} ${prefix}${path}`);
      
      // 注册路由  app.get('/', ctx => {})
      router[method](prefix+path, routes[key])
    })
  })
  return router
}

// 测试一下
initRouter()

// 根目录下终端运行 node index.js 打印结果:
// 正在映射地址: GET /
// 正在映射地址: GET /detail
复制代码

能看到如上的打印说明我们没有走错,我们将 routes > index.js 中的代码读取到,用koa-router将其注册为路由,现在 routes > index.js 中的代码已经合理了,奶思!测试没问题,删掉测试的调用,将 initRouter 抛出,当前 wx-loader.js中完成代码:

// 根目录 > wn-loader.js

const fs = require('fs')
const path = require('path')
const Router = require('koa-router')

// 读取目录和文件
function load(dir, cb) { // dir = routes
  // 获取绝对路径
  const url = path.resolve(__dirname, dir)
  // 读取目录
  const files = fs.readdirSync(url)
  // 遍历
  files.forEach(filename => {
    // index.js 去除扩展名
    filename = filename.replace('.js', '')
    const file = require(url + '/' + filename)
    cb(filename, file)
  })
}

// 加载路由
function initRouter() {
  const router = new Router()
  load('routes', (filename, routes) => {
    // 除了index文件,其他所有的文件中的内容都以文件名来设置路由前缀
    const prefix = filename === 'index' ? '' : `/${filename}`
    // routes 就是上一次打印结果中的对象
    Object.keys(routes).forEach(key => {
      const [method, path] = key.split(' ')  // ['get', '/']
      console.log(`正在映射地址: ${method.toLocaleUpperCase()} ${prefix}${path}`);
      
      // 注册路由  app.get('/', ctx => {})
      router[method](prefix+path, routes[key])
    })
  })
  return router
}

module.exports = {
  initRouter
}
复制代码

既然路由的写法合理了,我们可不可以运行项目了呢,修改入口文件 index.js:

// 根目录 > index.js

const app = new (require('koa'))()
- const router = require('./routes')  // 删除
+ const { initRouter } = require('./wn-loader')  // 增加
app.use(initRouter().routes())
app.listen(3000)
复制代码

在根目录下运行 node index.js,项目运行,在浏览器 http://localhost:3000 || http://localhost:3000/detail 可以看到效果

image.png

image.png

一切尽在掌握中!

3. 初始化控制层

我们现在是直接在路由的回调中向客户端输出内容的,实际开发中,我们会创建一个控制层,将向客户端输出内容的代码解耦出来

那么,现在在根目录下创建 controller > home.js 写上一小段逻辑

// controller > home.js
module.exports = {
  index: async ctx => {
    ctx.body = 'Ctrl Index'
  },
  detail: async ctx => {
    ctx.body = 'Ctrl Detail'
  }
}
复制代码

秉承着代码风格统一的原则,controller 当中的代码也抛出一个对象。同样的操作我们需要再来一次,因为此时controller中的代码属于耍流氓了,想个办法让它变得合理化,去到 wn-loader.js中初始化控制层

// 根目录 > wn-loader.js

... (部分代码省略)

// 初始化控制层
function initController() {
  const controllers = {}
  // 读取 controller 文件夹下的所有的文件
  load('controller', (filename, controller) => {
    controllers[filename] = controller
  })
  return controllers
}

module.exports = {
  initRouter,
  initController
}

// 注:得到的controllers 对象此刻应该长这样
// {
//   'home': {
//      index: async ctx => {
//        ctx.body = 'Ctrl Index'
//      },
//      detail: async ctx => {
//        ctx.body = 'Ctrl Detail'
//      }
//    }
// }

复制代码

initController函数抛出需要在路由当中得到使用,继续解耦当前已有的代码,在根目录下创建 wn.js,当前的目录结构:

image.png

同步一下目录结构没有问题的话就继续往下写了(真保姆)

4. 解耦出来框架主体

// 根目录 > wn.js

const Koa = require('koa')
const { initRouter, initController } = require('./wn-loader')

// 定义一个自己的框架名
class wn {
  constructor() {
    // 挂载上koa实例
    this.$app = new Koa()
    // 分别初始化路由控制层和路由层 注意顺序
    this.$ctrl = initController()
    this.$router = initRouter()
    // koa的注册路由写法
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`wn服务已启动成功 端口: ${port}`);
    })
  }
}

module.exports = wn
复制代码

初始化controller的函数调用完后,现在面临一个新的问题,this.$ctrl 这个对象要怎么在路由当中生效?让我猜猜,客官们看到这里了应该就心里有数了。注意刚才代码中的注释 “注意顺序”,我们先加载的 controller,再加载的路由,也就是说当路由生效的时候 wn 这个实例对象上已经具备 controllers 对象了!美妙!,那么我们把 wn 实例作为实参传给路由岂不是妙哉!修改代码:

// 根目录 > wn.js
...(省略部分代码)
this.$router = initRouter(this) // 传入this
复制代码

再将路由文件中的index.js的使用方式调整为:

// routes > index.js  原本抛出对象,现在抛出函数体
module.exports = app => ({
  'get /': app.$ctrl.home.index,
  'get /detail': app.$ctrl.home.detail
})
复制代码

顺势再修改 wn-loader.js 当中的函数定义:

// 根目录 > wn-loader.js
...(省略部分代码)

// 加载路由
function initRouter(app) {
  const router = new Router()
  load('routes', (filename, routes) => { // routes 现在代表的是函数体了
    // +++保留原对象的写法
    routes = typeof routes === 'function' ? routes(app) : routes  

    const prefix = filename === 'index' ? '' : `/${filename}`
    Object.keys(routes).forEach(key => {
      const [method, path] = key.split(' ')  // ['get', '/']
      console.log(`正在映射地址: ${method.toLocaleUpperCase()} ${prefix}${path}`);

      // 注册路由
      router[method](prefix+path, async ctx => { // 修改回调函数
        app.ctx = ctx  // 往wn实例对象上添加ctx属性值为koa中的ctx
        await routes[key](app)  // 为routes > index.js 函数提供参数
      })
    })
  })
  return router
}
复制代码

一系列的修改完成之后,最后,框架主题已经被提取出来了,运行该框架的代码就该适当调整了:

// 根目录 > index.js   只需要三行就可以了

const wn = require('./wn')
const app = new wn()
app.start(3000)
复制代码

node index.js 运行试试:

image.png

再一次如我们的预期一般(美滋滋)你可以去浏览器查看返回

5. 初始化服务层

routes 和 controller 都处理好之后,让我想想,当我们需要连接数据库做连接时(比如mysql)我们需要一些数据库的属性设置,需要建立线程池的连接等一些列操作,按照我们想要的约定思路来,在根目录下创建 service > user.js

// service > user.js

const delay = (data, tick) => new Promise(resolve => {
  setTimeout(() => {
    resolve(data)
  }, tick)
})

module.exports = {
  getName() {
    return delay('jerry', 1000) // 模拟异步
  },
  getAge() {
    return 20
  }
}
复制代码

这里的逻辑应该是向数据库拿数据,我们先模拟一下,代码比较简单就不解释了。service 已经写成这样了,老规矩,先让这个文件夹下的代码都能被读取到

// wn-loader.js
...(省略部分代码)

// 初始化服务层
function initService() {
  const services = {}
  load('service', (filename, service) => {
    services[filename] = service
  })
  return services
}

module.exports = {
  initRouter,
  initController,
  initService,
  loadConfig
}

// services得到的应该是这样的
// {
//  'user': {...}
// }
复制代码
// wn.js
const Koa = require('koa')
const { initRouter, initController, initService } = require('./wn-loader')

class wn {
  constructor(conf) {
    this.$app = new Koa()
    this.$ctrl = initController()
    this.$service = initService() // +++ 新增 service 的调用
    this.$router = initRouter(this)
    this.$app.use(this.$router.routes())
  }

  start(port) {
    this.$app.listen(port, () => {
      console.log(`wn服务已启动成功 端口: ${port}`);
    })
  }
}

module.exports = wn
复制代码

wn对象上现在可以直接通过 $service 获取到 service 层的代码,为了测试方便,我们从新在routes 下新建 user.js

// routes > user.js

module.exports = {
  'get /': async (app) => {
    const name = await app.$service.user.getName()
    app.ctx.body = '用户' + name
  },
  'get /detail': async (app) => {
    app.ctx.body = '用户年龄' + app.$service.user.getAge()
  }
}
复制代码

因为加载路由的时候保留了对象的写法,所以直接抛出一个对象是没有问题的,这里写好user.js后我们便拥有了 http://localhost:3000/user 接口可用了(路由前缀已经配过了),运行项目测试一下

image.png 但是!现在再去http://localhost:3000 这个路径发现 Not Found 了,这是因为我们控制层的代码没有及时修改

// controller > home.js
module.exports = {
  index: async app => {
    app.ctx.body = await 'Ctrl Index'
  },
  detail: async app => {
    app.ctx.body = 'Ctrl Detail'
  }
}
复制代码

相信你只要是认真看到了这里,那么代码为什么要这样修改你是完全能明白的。终于,service文件 被我们处理好了,到这里我们已经完成了百分之八十的里程,其实定义成这样已经可以正常使用了。当然为了更好的体验,以及自己以后写全栈代码方便,我们在集成一个数据库配置项

6. 加载model层,创建数据表

这里的操作以 mySql 为例,你如果使用 mongodb或者别的数据库的话就自由发挥啦,原理搜是一样的,相信你自己,思考和动手其实只差一步!

首先需要安装 sequelize 帮助我们建表 npm i sequelize,在根目录下创建文件夹 model > user.js

// model > user.js
// 映射数据库中对应的表
const { STRING } = require('sequelize')
module.exports = {
  schema: {
    name: STRING(30)
  },
  options: {
    timestamps: false
  }
}
复制代码

借助 sequelize 去对应的表当中创建 字段,等等,问题是库都没有,哪来的表?就知道小机灵鬼你该问这个问题了,来吧我们去根目录下创建一个config配置文件里面创建 index.js

// config > index.js
module.exports = {
  db: {
    dialect: 'mysql',
    host: 'localhost',
    database: 'test',  // 我是先在mysql中手动创建好了一个test库
    username: 'root',
    password: '123456' // 放你自己的密码
  }
}
复制代码

然后?然后当然是想办法让这份配置能启用啦。在 wn-loader.js 中添加代码

// 根目录 > wn-loader.js
...(省略部分代码)

const Sequelize = require('sequelize')
function loadConfig(app) {
  load('config', (filename, conf) => {
    if (conf.db) {
      app.$db = new Sequelize(conf.db) // 初始化db操作

      // 加载模型
      app.$model = {}
      load('model', (filename, { schema, options }) => {
        // 利用Sequelize把model下的所有文件都映射成数据库表
        app.$model[filename] = app.$db.define(filename, schema, options) 
      })
      app.$db.sync() // 模块同步
    }
  })
}

module.exports = {
  initRouter,
  initController,
  initService,
  loadConfig
}
复制代码

这里我们定义函数自动读取到config文件夹下的配置,用sequelize将model下的模版映射成数据库表,loadConfig只要调用就能映射出mysql表,把它引入到 wn.js 中调用

const Koa = require('koa')
const { ..., loadConfig } = require('./wn-loader')

class wn {
  constructor(conf) {
    loadConfig(this)
    (...太多重复的代码就不写了)
  }
}

module.exports = wn
复制代码

尝试运行项目终端会提示你 Error: Please install mysql2 package manually 装一个 npm i mysql2 再次运行项目你会看到你想要的

image.png

此时的心情用一个成语描述最为恰当 --- 舒舒服服。自行去 test 库 user 表下面加一条数据之后修改 controller > home.js中的代码 用上数据库查找

// controller > home.js

index: async app => {
  // app.ctx.body = await 'Ctrl Index'
  app.ctx.body = await app.$model.user.findOne({ where: { id: '1' } });
},
复制代码

只修改index函数是为了测试,之后这里的所有函数都应该这么写。访问 http://localhost:3000/

image.png

写在最后

点这里:源码地址在 github 上

完成这样一个mvc后端框架的的二次封装其实不难,很多小伙伴会停步在思考阶段,觉得很复杂,那只是你觉得而已,当然咱们不是说写代码之前不用思考,只是说别让困难阻碍你的双手,项目中还有很多可以完善的地方,比如,如果你需要集成中间件的功能,你可以再如何封装它。这个问题就留着小伙伴自己发挥啦

文章分类
前端
文章标签