Express
和Koa
作為輕量級的web框架,雖然靈活簡單,幾行程式碼就可以啟動伺服器了,但是隨著業務的複雜,你很快就會發現,需要自己手動配置各種中介軟體,並且由於這類web框架並不約束專案的目錄結構,因此不同水平的程式設計師搭出的專案質量也是千差萬別。為了解決上述問題,社群也出現了各種基於Express
和Koa
的上層web框架,比如Egg.js和Nest.js
我目前所在的公司,也是基於Koa
並結合自身業務需求,實現了一套MVC
開發框架。我司的Node主要是用來承擔BFF層,並不涉及真正的業務邏輯,因此該框架只是對Koa
進行了相對簡單的封裝,內建了一些通用的業務元件(比如身份驗證,代理轉發),通過約定好的目錄結構,自動注入路由和一些全域性方法
最近摸魚時間把該框架的原始碼簡單看了一遍,收穫還是很大,於是決定動手實現了一個玩具版的MVC框架
框架使用
│ 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
這個框架
基本框架
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物件
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框架可以很方便的整合進來,同時利用腳手架工具,提供了開箱即用的專案模板,為業務減少了很多不必要的開發和運維成本