Express
和Koa
作为轻量级的web框架,虽然灵活简单,几行代码就可以启动服务器了,但是随着业务的复杂,你很快就会发现,需要自己手动配置各种中间件,并且由于这类web框架并不约束项目的目录结构,因此不同水平的程序员搭出的项目质量也是千差万别。为了解决上述问题,社区也出现了各种基于Express
和Koa
的上层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
类,我们通过传入routes
和middlewares
两个参数,来告诉框架如何渲染路由和启动中间件
我们访问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-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;
参考代码-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 && 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框架可以很方便的整合进来,同时利用脚手架工具,提供了开箱即用的项目模板,为业务减少了很多不必要的开发和运维成本