Express
和Koa
做爲輕量級的web框架,雖然靈活簡單,幾行代碼就能夠啓動服務器了,可是隨着業務的複雜,你很快就會發現,須要本身手動配置各類中間件,而且因爲這類web框架並不約束項目的目錄結構,所以不一樣水平的程序員搭出的項目質量也是千差萬別。爲了解決上述問題,社區也出現了各類基於Express
和Koa
的上層web框架,好比Egg.js和Nest.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.js
github
const routes = [ { match: '/', controller: 'home.index' }, { match: '/list', controller: 'home.fetchList', method: 'post' } ]; module.exports = routes;
middlewares/index.js
web
const middleware = () => { return async (context, next) => { console.log('自定義中間件'); await next() } } module.exports = [middleware()];
app.js
npm
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
類,咱們經過傳入routes
和middlewares
兩個參數,來告訴框架如何渲染路由和啓動中間件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
這個框架
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-bodyparser
,koa-router
,metrics
性能監控,健康檢查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;
咱們規定了框架的路由規則:
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 && 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;
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框架能夠很方便的整合進來,同時利用腳手架工具,提供了開箱即用的項目模板,爲業務減小了不少沒必要要的開發和運維成本