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 step2node 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);}];// 最初应用applyrouter[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框架能够很不便的整合进来,同时利用脚手架工具,提供了开箱即用的我的项目模板,为业务缩小了很多不必要的开发和运维老本