乐趣区

关于node.js:基于Koa2打造属于自己的MVC框架

ExpressKoa 作为轻量级的 web 框架,尽管灵便简略,几行代码就能够启动服务器了,然而随着业务的简单,你很快就会发现,须要本人手动配置各种中间件,并且因为这类 web 框架并不束缚我的项目的目录构造,因而不同程度的程序员搭出的我的项目品质也是千差万别。为了解决上述问题,社区也呈现了各种基于 ExpressKoa的下层 web 框架,比方 Egg.js 和 Nest.js

我目前所在的公司,也是基于 Koa 并联合本身业务需要,实现了一套 MVC 开发框架。我司的 Node 次要是用来承当 BFF 层,并不波及真正的业务逻辑,因而该框架只是对 Koa 进行了绝对简略的封装,内置了一些通用的业务组件(比方身份验证,代理转发),通过约定好的目录构造,主动注入路由和一些全局办法

最近 摸鱼工夫 把该框架的源码简略看了一遍,播种还是很大,于是决定入手实现了一个 玩具版 的 MVC 框架

源代码地址

框架应用

参考代码 -step1

│  app.js
│  routes.js
│  
├─controllers
│   home.js
│      
├─middlewares
│   index.js
│      
├─my-node-mvc # 咱们之后将要实现的框架
|
|
├─services
│   home.js
│      
└─views
    home.html       

my-node-mvc是之后咱们将要实现的 MVC 框架,首先咱们来看看最初的应用成果

routes.js

const routes = [
  {
    match: '/',
    controller: 'home.index'
  },
  {
    match: '/list',
    controller: 'home.fetchList',
    method: 'post'
  }
];
module.exports = routes;

middlewares/index.js

const middleware = () => {return async (context, next) => {console.log('自定义中间件');
    await next()}
}
module.exports = [middleware()];

app.js

const {App} = require('./my-node-mvc');
const routes = require('./routes');
const middlewares = require('./middlewares');

const app = new App({
  routes,
  middlewares,
});

app.listen(4445, () => {console.log('app start at: http://localhost:4445');
})

my-node-mvc裸露了一个 App 类,咱们通过传入 routesmiddlewares两个参数,来通知框架如何渲染路由和启动中间件

咱们拜访 http://localhost:4445 时,首先会通过咱们的自定义中间件

async (context, next) => {console.log('自定义中间件');
  await next()}

之后会匹配到 routes.js 外面的这段门路

{
  match: '/',
  controller: 'home.index'
}

而后框架回去找 controllers 目录夹下的 home.js,新建一个Home 对象并且调用它的 index 办法,于是页面就渲染了 views 目录夹下的home.html

controllers/home.js

const {Controller} = require('../my-node-mvc');

// 裸露了一个 Controller 父类,所以的 controller 都继承它,能力注入 this.ctx 对象

// this.ctx 除了有 koa 自带的办法和属性外,还有 my-node-mvc 框架拓展的自定义办法和属性
class Home extends Controller {async index() {await this.ctx.render('home');
  }

  async fetchList() {const data = await this.ctx.services.home.getList();
    ctx.body = data;
  }
}

module.exports = Home;

同理拜访 http://localhost:4445/list 匹配到了

{
  match: '/list',
  controller: 'home.fetchList'
}

于是调用了 Home 对象的 fetchList 办法,这个办法又调用了 services 目录下的 home 对象的 getList 办法,最初返回 json 数据

services/home.js

const {Service} = require('../my-node-mvc')

const posts = [{
  id: 1,
  title: 'Fate/Grand Order',
}, {
  id: 2,
  title: 'Azur Lane',
}];

// 裸露了一个 Service 父类,所以的 service 都继承它,能力注入 this.ctx 对象
class Home extends Service {async getList() {return posts}
}

module.exports = Home

至此,一个最简略的MVC web 流程曾经跑通

<font color=”orange”> 在开始教程之前,最好心愿你有 Koa 源码的浏览教训,能够参考我之前的文章:Koa 源码浅析 </font>

接下来,咱们会一步步实现 my-node-mvc 这个框架

根本框架

参考代码 -step2

my-node-mvc是基于 Koa 的,因而首先咱们须要装置Koa

npm i koa

my-node-mvc/app.js

const Koa = require('koa');

class App extends Koa {constructor(options={}) {super();
  }
}

module.exports = App;

咱们只有简略的 extend 继承父类 Koa 即可

my-node-mvc/index.js

// 将 App 导出
const App = require('./app');

module.exports = {App,}

咱们来测试下

# 进入 step2 目录
cd step2
node app.js

拜访 http://localhost:4445/ 发现服务器启动胜利

于是,一个最简略的封装曾经实现

内置中间件

咱们的 my-node-mvc 框架须要内置一些最根底的中间件,比方 koa-bodyparser,koa-router, koa-views 等,只有这样,能力免去咱们每次新建我的项目都须要反复装置中间件的麻烦

内置的中间件个别又分为两种:

  • 内置根底中间件:比方 koa-bodyparserkoa-routermetrics 性能监控,健康检查
  • 内置业务中间件:框架联合业务需要,把各部门通用的性能集成在业务中间件,比方单点登录,文件上传
npm i uuid koa-bodyparser ejs koa-views

咱们来尝试新建一个业务中间件

my-node-mvc/middlewares/init.js

const uuid = require('uuid');

module.exports = () => {
  // 每次申请生成一个 requestId
  return async (context, next) => {const id = uuid.v4().replace(/-/g, '')
    context.state.global = {requestId: id}
    await next()}
}

my-node-mvc/middlewares/index.js

const init = require('./init');
const views = require('koa-views');
const bodyParser = require('koa-bodyparser');

// 把业务中间件 init 和根底中间件 koa-bodyparser koa-views 导出
module.exports = {
  init,
  bodyParser,
  views,
}

当初,咱们须要把这几个中间件在 App 初始化时调用

my-node-mvc/index.js

const Koa = require('koa');
const middlewares = require('./middlewares');

class App extends Koa {constructor(options={}) {super();

    const {projectRoot = process.cwd(), rootControllerPath, rootServicePath, rootViewPath } = options;
    this.rootControllerPath = rootControllerPath || path.join(projectRoot, 'controllers');
    this.rootServicePath = rootServicePath || path.join(projectRoot, 'services');
    this.rootViewPath = rootViewPath || path.join(projectRoot, 'views');

    this.initMiddlewares();}

  initMiddlewares() {
    // 应用 this.use 注册中间件
    this.use(middlewares.init());
    this.use(middlewares.views(this.rootViewPath, { map: { html: 'ejs'} }))
    this.use(middlewares.bodyParser());
  }
}

module.exports = App;

批改下启动step2/app.js

app.use((ctx) => {ctx.body = ctx.state.global.requestId})

app.listen(4445, () => {console.log('app start at: http://localhost:4445');
})

于是每次拜访 http://localhost:4445 都能返回不同的requestId

业务中间件

除了 my-node-mvc 内置的中间件外,咱们还能传入本人写的中间件,让 my-node-mvc 帮咱们启动

step2/app.js

const {App} = require('./my-node-mvc');
const routes = require('./routes');
const middlewares = require('./middlewares');

// 传入咱们的业务中间件 middlewares,是个数组
const app = new App({
  routes,
  middlewares,
});

app.use((ctx, next) => {ctx.body = ctx.state.global.requestId})

app.listen(4445, () => {console.log('app start at: http://localhost:4445');
})

my-node-mvc/index.js

const Koa = require('koa');
const middlewares = require('./middlewares');

class App extends Koa {constructor(options={}) {super();
    this.options = options;

    this.initMiddlewares();}

  initMiddlewares() {
    // 接管传入进来的业务中间件
    const {middlewares: businessMiddlewares} = this.options;
    // 应用 this.use 注册中间件
    this.use(middlewares.init())
    this.use(middlewares.bodyParser());

    // 初始化业务中间件
    businessMiddlewares.forEach(m => {if (typeof m === 'function') {this.use(m);
      } else {throw new Error('中间件必须是函数');
      }
    });
  }
}

module.exports = App;

于是咱们的业务中间件也能启动胜利了

step2/middlewares/index.js

const middleware = () => {return async (context, next) => {console.log('自定义中间件');
    await next()}
}

module.exports = [middleware()];

全局办法

咱们晓得,Koa内置的对象 ctx 上曾经挂载了很多办法,比方 ctx.cookies.get() ctx.remove() 等等,在咱们的 my-node-mvc 框架里,咱们其实还能增加一些全局办法

如何在 ctx 上持续增加办法呢?惯例的思路是写一个中间件,把办法挂载在 ctx 上:

const utils = () => {return async (context, next) => {context.sayHello = () => {console.log('hello');
    }
    await next()}
}

// 应用中间件
app.use(utils());

// 之后的中间件都能应用这个办法了
app.use((ctx, next) => {ctx.sayHello();
})

不过这要求咱们将 utils 中间件放在最顶层,这样之后的中间件能力持续应用这个办法

咱们能够换个思路:每次客户端发送一个 http 申请,Koa都会调用 createContext 办法,该办法会返回一个全新的 ctx,之后这个ctx 会被传递到各个中间件里

关键点就在 createContext,咱们能够重写createContext 办法,在把 ctx 传递给中间件之前,就先注入咱们的全局办法

my-node-mvc/index.js

const Koa = require('koa');

class App extends Koa {createContext(req, res) {
    // 调用父级办法
    const context = super.createContext(req, res);
    // 注入全局办法
    this.injectUtil(context);

    // 返回 ctx
    return context
  }

  injectUtil(context) {context.sayHello = () => {console.log('hello');
    }
  }
}

module.exports = App;

匹配路由

参考代码 -step3

咱们规定了框架的路由规定:

const routes = [
  {
    match: '/', // 匹配门路
    controller: 'home.index', // 匹配 controller 和办法
    middlewares: [middleware1, middleware2], // 路由级别的中间件,先通过路由中间件,最初达到 controller 的某个办法
  },
  {
    match: '/list',
    controller: 'home.fetchList',
    method: 'post', // 匹配 http 申请
  }
];

思考下如何通过 koa-router 实现该配置路由?

# https://github.com/ZijianHe/koa-router/issues/527#issuecomment-651736656
# koa-router 9.x 版本升级了 path-to-regexp
# router.get('/*', (ctx) => {ctx.body = 'ok'}) 变成这种写法:router.get('(.*)', (ctx) => {ctx.body = 'ok'})
npm i koa-router

新建内置路由中间件
my-node-mvc/middlewares/router.js

const Router = require('koa-router');
const koaCompose = require('koa-compose');

module.exports = (routerConfig) => {const router = new Router();

  // Todo 对传进来的 routerConfig 路由配置进行匹配

  return koaCompose([router.routes(), router.allowedMethods()])
}

留神我最初应用了 koaCompose 把两个办法合成了一个,这是因为 koa-router 最原始办法须要调用两次 use 能力注册胜利中间件

const router = new Router();

router.get('/', (ctx, next) => {// ctx.router available});

app
  .use(router.routes())
  .use(router.allowedMethods());

应用了 KoaCompose 后,咱们注册时只须要调用一次 use 即可

class App extends Koa {initMiddlewares() {const { routes} = this.options;
    
    // 注册路由
    this.use(middlewares.route(routes));
  }
}

当初咱们来实现具体的路由匹配逻辑:

module.exports = (routerConfig) => {const router = new Router();

  if (routerConfig && routerConfig.length) {routerConfig.forEach((routerInfo) => {let { match, method = 'get', controller, middlewares} = routerInfo;
      let args = [match];

      if (method === '*') {method = 'all'}

      if ((middlewares && middlewares.length)) {args = args.concat(middlewares)
      };

      controller && args.push(async (context, next) => {
        // Todo 找到 controller
        console.log('233333');
        await next();});


      if (router[method] && router[method].apply) {
        // apply 的妙用
        // router.get('/demo', fn1, fn2, fn3);
        router[method].apply(router, args)
      }
    })
  }

  return koaCompose([router.routes(), router.allowedMethods()])
}

这段代码有个奇妙的技巧就是应用了一个 args 数组来收集路由信息

{
  match: '/neko',
  controller: 'home.index',
  middlewares: [middleware1, middleware2],
  method: 'get'
}

这份路由信息,如果要用 koa-router 实现匹配,应该这样写:

// middleware1 和 middleware2 是咱们传进来的路由级别中间件
// 最初申请会传递到 home.index 办法
router.get('/neko', middleware1, middleware2, home.index);

因为匹配规定都是咱们动静生成的,因而不能像下面那样写死,于是就有了这个技巧:

const method = 'get';

// 通过数组收集动静的规定
const args = ['/neko', middleware1, middleware2, async (context, next) => {
  // 调用 controller 办法
  await home.index(context, next);
}];

// 最初应用 apply
router[method].apply(router, args)

注入 Controller

后面的路由中间件,咱们还短少最要害的一步:找到对应的 Controller 对象

controller && args.push(async (context, next) => {
  // Todo 找到 controller
  await next();});

咱们之前曾经约定过我的项目的 controllers 文件夹默认寄存 Controller 对象,因而只有遍历该文件夹,找到名为 home.js 的文件,而后调用这个 controller 的相应办法即可

npm i glob

新建my-node-mvc/loader/controller.js

const glob = require('glob');
const path = require('path');

const controllerMap = new Map(); // 缓存文件名和对应的门路
const controllerClass = new Map(); // 缓存文件名和对应的 require 对象

class ControllerLoader {constructor(controllerPath) {this.loadFiles(controllerPath).forEach(filepath => {const basename = path.basename(filepath);
      const extname = path.extname(filepath);
      const fileName = basename.substring(0, basename.indexOf(extname));

      if (controllerMap.get(fileName)) {throw new Error(`controller 文件夹下有 ${fileName}文件同名!`)
      } else {controllerMap.set(fileName, filepath);
      }
    })
  }

  loadFiles(target) {const files = glob.sync(`${target}/**/*.js`)
    return files
  }

  getClass(name) {if (controllerMap.get(name)) {if (!controllerClass.get(name)) {const c = require(controllerMap.get(name));
        // 只有用到某个 controller 才 require 这个文件
        controllerClass.set(name, c);
      }
      return controllerClass.get(name);
    } else {throw new Error(`controller 文件夹下没有 ${name}文件 `)
    }
  }

}

module.exports = ControllerLoader

因为 controllers 文件夹下可能有十分多的文件,因而咱们没必要我的项目启动时就把所有的文件 require 进来。当某个申请须要调用home controller 时,咱们才动静加载require('/my-app/controllers/home')。同一模块标识,node 第一次加载实现时会缓存该模块,再次加载时,将会从缓存中获取

批改my-node-mvc/app.js

const ControllerLoader = require('./loader/controller');
const path = require('path');

class App extends Koa {constructor(options = {}) {super();
    this.options = options;

    const {projectRoot = process.cwd(), rootControllerPath } = options;
    // 默认 controllers 目录,你也能够通过配置 rootControllerPath 参数指定其余门路
    this.rootControllerPath = rootControllerPath || path.join(projectRoot, 'controllers'); 
    this.initController();
    this.initMiddlewares();}

  initController() {this.controllerLoader = new ControllerLoader(this.rootControllerPath);
  }

  initMiddlewares() {
    // 把 controllerLoader 传给路由中间件
    this.use(middlewares.route(routes, this.controllerLoader))
  }
}

module.exports = App;

my-node-mvc/middlewares/router.js

// 省略其余代码

controller && args.push(async (context, next) => {
  // 找到 controller home.index
  const arr = controller.split('.');
  if (arr && arr.length) {const controllerName = arr[0]; // home
    const controllerMethod = arr[1]; // index
    const controllerClass = loader.getClass(controllerName); // 通过 loader 获取 class

    // controller 每次申请都要从新 new 一个,因为每次申请 context 都是新的
    // 传入 context 和 next
    const controller = new controllerClass(context, next);
    if (controller && controller[controllerMethod]) {await controller[controllerMethod](context, next);
    }
  } else {await next();
  }
});

新建my-node-mvc/controller.js

class Controller {constructor(ctx, next) {
    this.ctx = ctx;
    this.next = next;
  }
}

module.exports = Controller;

咱们的 my-node-mvc 会提供一个 Controller 基类,所有的业务 Controller 都要继承于它,于是办法里就能取到 this.ctx

my-node-mvc/index.js

const App = require('./app');
const Controller = require('./controller');

module.exports = {
  App,
  Controller, // 裸露 Controller
}
const {Controller} = require('my-node-mvc');

class Home extends Controller {async index() {await this.ctx.render('home');
  }
}

module.exports = Home;

注入 Services

const {Controller} = require('my-node-mvc');

class Home extends Controller {async fetchList() {const data = await this.ctx.services.home.getList();
    ctx.body = data;
  }
}

module.exports = Home;

this.ctx对象上会挂载一个 services 对象,外面蕴含我的项目根目录 Services 文件夹下所有的 service 对象

新建my-node-mvc/loader/service.js

const path = require('path');
const glob = require('glob');

const serviceMap = new Map();
const serviceClass = new Map();
const services = {};

class ServiceLoader {constructor(servicePath) {this.loadFiles(servicePath).forEach(filepath => {const basename = path.basename(filepath);
      const extname = path.extname(filepath);
      const fileName = basename.substring(0, basename.indexOf(extname));

      if (serviceMap.get(fileName)) {throw new Error(`servies 文件夹下有 ${fileName}文件同名!`)
      } else {serviceMap.set(fileName, filepath);
      }

      const _this = this;

      Object.defineProperty(services, fileName, {get() {if (serviceMap.get(fileName)) {if (!serviceClass.get(fileName)) {
              // 只有用到某个 service 才 require 这个文件
              const S = require(serviceMap.get(fileName));
              serviceClass.set(fileName, S);
            }
            const S = serviceClass.get(fileName);
            // 每次 new 一个新的 Service 实例
            // 传入 context
            return new S(_this.context);
          }
        }
      })

    });
  }

  loadFiles(target) {const files = glob.sync(`${target}/**/*.js`)
    return files
  }

  getServices(context) {
    // 更新 context
    this.context = context;
    return services;
  }

}

module.exports = ServiceLoader

代码根本和 my-node-mvc/loader/controller.js 一个套路,只不过用 Object.defineProperty 定义了 services 对象的 get 办法,这样调用 services.home 时,就能主动require('/my-app/services/home')

而后,咱们还须要把这个 services 对象挂载到 ctx 对象上。还记得之前怎么定义全局办法的吗?还是一样的套路(封装的 千层套路

class App extends Koa {constructor() {this.rootViewPath = rootViewPath || path.join(projectRoot, 'views');
    this.initService();}

  initService() {this.serviceLoader = new ServiceLoader(this.rootServicePath);
  }

  createContext(req, res) {const context = super.createContext(req, res);
    // 注入全局办法
    this.injectUtil(context);

    // 注入 Services
    this.injectService(context);

    return context
  }

  injectService(context) {
    const serviceLoader = this.serviceLoader;

    // 给 context 增加 services 对象
    Object.defineProperty(context, 'services', {get() {return serviceLoader.getServices(context)
      }
    })
  }
}

同理,咱们还须要提供一个 Service 基类,所有的业务 Service 都要继承于它

新建my-node-mvc/service.js

class Service {constructor(ctx) {this.ctx = ctx;}
}

module.exports = Service;

my-node-mvc/index.js

const App = require('./app');
const Controller = require('./controller');
const Service = require('./service');

module.exports = {
  App,
  Controller,
  Service, // 裸露 Service
}
const {Service} = require('my-node-mvc');

const posts = [{
  id: 1,
  title: 'this is test1',
}, {
  id: 2,
  title: 'this is test2',
}];

class Home extends Service {async getList() {return posts;}
}

module.exports = Home;

总结

本文基于 Koa2 从零开始封装了一个很根底的 MVC 框架,心愿能够给读者提供一些框架封装的思路和灵感,更多的框架细节,能够看看我写的little-node-mvc

当然,本文的封装是十分简陋的,你还能够持续联合公司理论状况,欠缺更多的性能:比方提供一个 my-node-mvc-template 我的项目模板,同时再开发一个命令行工具 my-node-mvc-cli 进行模板的拉取和创立

其中,内置中间件和框架的联合能力算是给封装注入了真正的灵魂,我司外部封装了很多通用的业务中间件:鉴权,日志,性能监控,全链路追踪,配置核心等等公有 NPM 包,通过自研的 Node 框架能够很不便的整合进来,同时利用脚手架工具,提供了开箱即用的我的项目模板,为业务缩小了很多不必要的开发和运维老本

退出移动版