基於Koa2打造屬於本身的MVC框架

ExpressKoa做爲輕量級的web框架,雖然靈活簡單,幾行代碼就能夠啓動服務器了,可是隨着業務的複雜,你很快就會發現,須要本身手動配置各類中間件,而且因爲這類web框架並不約束項目的目錄結構,所以不一樣水平的程序員搭出的項目質量也是千差萬別。爲了解決上述問題,社區也出現了各類基於ExpressKoa的上層web框架,好比Egg.jsNest.jsjavascript

我目前所在的公司,也是基於Koa並結合自身業務需求,實現了一套MVC開發框架。我司的Node主要是用來承擔BFF層,並不涉及真正的業務邏輯,所以該框架只是對Koa進行了相對簡單的封裝,內置了一些通用的業務組件(好比身份驗證,代理轉發),經過約定好的目錄結構,自動注入路由和一些全局方法html

最近摸魚時間把該框架的源碼簡單看了一遍,收穫仍是很大,因而決定動手實現了一個玩具版的MVC框架java

源代碼地址node

框架使用

參考代碼-step1git

│  app.js
│  routes.js
│  
├─controllers
│   home.js
│      
├─middlewares
│   index.js
│      
├─my-node-mvc # 咱們以後將要實現的框架
|
|
├─services
│   home.js
│      
└─views
    home.html

my-node-mvc是以後咱們將要實現的MVC框架,首先咱們來看看最後的使用效果程序員

routes.jsgithub

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

middlewares/index.jsweb

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

app.jsnpm

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兩個參數,來告訴框架如何渲染路由和啓動中間件json

咱們訪問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框架能夠很方便的整合進來,同時利用腳手架工具,提供了開箱即用的項目模板,爲業務減小了不少沒必要要的開發和運維成本

相關文章
相關標籤/搜索