文章首发于我的个人博客 ExpressJs中间件原理学习
ExpressJs是一个很受欢迎 Node.js Web 应用程序框架。在使用过ExpressJs后,想更深层次的学习一下这个框架的源码设计,做到知其所以然。该篇文章会讲解如何模仿Express的实现写一个简易的MyExpress,从而来记录这个框架的源码学习。
Express源码结构
Express源码结构看起来十分的一目了然。
- middleware ———— 处理中间件相关的内容
- init.js
- query.js
- router ———— express路由相关的内容
- index.js
- layer.js
- route.js
- application.js ———— app 模块
- express.js ———— express应用入口文件
- request.js ———— 用于扩展http request
- response.js ———— 用于扩展http response
- utils.js ———— 工具函数
- view.js ———— 模板渲染相关
下面会通过模仿Express,实现一个MyExpress,简化学习Express的路由模块和中间件的源码设计思想。
实现一个基本的MyExpress
第一步要实现的MyExpress只是包含应用的创建和响应客户端的get请求。要实现的功能代码如下:
const express = require("./myexpress");
// 创建express实例
const app = express();
// 处理/路由请求
app.get("/", (req, res) => {
res.end("root");
});
// 处理/test 路由请求
app.get("/test", (req, res) => {
res.end("test");
});
// 监听3000端口
app.listen(3000, () => {
console.log("server listening on 3000");
});
根据上面的要求,MyExpress需要如下功能:
- MyExpress的Application构造函数,并且该构造函数需要提供:
- listen原型方法,用于监听端口
- get 原型方法,用于收集和响应get路由
- 创建MyExpress app实例的工厂函数
跟Express一样,我们也会创建一个express.js,用来导出一个创建app实例的工厂函数,具体代码如下:
// express应用函数(可以看作一个构造函数)
const Application = require('./application')
function createApplication () {
return new Application()
}
module.exports = createApplication
在express.js中导入的application.js提供的是一个构造函数。
const http = require("http");
const url = require("url");
// 收集路由
const routes = [];
function Application() {}
// 实现get路由
Application.prototype.get = function (path, handler) {
routes.push({
path,
method: "GET",
handler,
});
};
// 监听和响应
// 内部调用http的createServer,获取req, res
Application.prototype.listen = function (...args) {
const server = http.createServer((req, res) => {
const { pathname } = url.parse(req.url);
const method = req.method.toLocaleLowerCase();
// 请求path和method相同,表示匹配路由
const route = routes.find(
(route) =>
pathname === route.path && method === route.method.toLocaleLowerCase()
);
if (!route) {
// 否则响应 "404"
return res.end("404");
}
// 响应该路由
route.handler(req, res);
});
// 监听端口
server.listen(...args);
};
module.exports = Application;
上面代码的关键部分就是listen原型方法。它用到了http模块的createServer方法,用来创建一个HTTP服务器实例。该方法接受一个回调函数,这个回调函数接受两个参数,分别是HTTP request对象和HTTP response对象。
所以可以从这一部分的MyExpress实现看出Express框架的核心就是对 http 模块的再一次封装。
优化MyExpress路由匹配
在上一步的application.js中,处理路由响应的逻辑的代码耦合度太高,不利于进一步扩展,根据Express的源码设计实现,我们要分离application.js中的路由功能,实现一个独立的路由模块,其主要功能有:
- 处理不同的请求方法。
- 处理匹配不同的请求路径。
- 处理动态路由路径参数。
处理第一个功能点,我们需要用到methods这个第三方库;处理第二、三两个功能点,我们需要用到path-to-regexp这个第三方库。这两个库其实也是Express中用到的。methods提供了 get,post,put,head,delete,options 等这些常用的http method;path-to-regexp用于路由的解析,可以把/user/:id/:name这样的路由转化为一个常规的正则表达式。
这一步改造后的application.js实现如下:
const http = require('http')
const Router = require('./router')
const methods = require('methods')
function App () {
// App的路由模块
this._router = new Router()
}
methods.forEach(method => {
// 响应不同的http方法请求
App.prototype[method] = function (path, handler) {
this._router[method](path, handler)
}
})
App.prototype.listen = function (...args) {
const server = http.createServer((req, res) => {
// 路由的处理交给路由模块处理
this._router.handler(req, res)
})
server.listen(...args)
}
module.exports = App
新增的路由模块对外提供了一个Router构造函数,实现了众多和http method对应的原型方法和一个路由处理的handler原型方法,实现如下:
// router/index.js
const url = require('url')
const methods = require('methods')
const pathRegexp = require('path-to-regexp')
function Router () {
// 收集应用的路由
this.stack = []
}
methods.forEach(method => {
// 收集不同的http方法请求路由
Router.prototype[method] = function (path, handler) {
this.stack.push({
path,
method,
handler
})
}
})
Router.prototype.handler = function (req, res) {
const { pathname } = url.parse(req.url)
const method = req.method.toLowerCase()
const route = this.stack.find(route => {
const keys = []
const regexp = pathRegexp(route.path, keys, {})
// 匹配路由路径
const match = regexp.exec(pathname)
if (match) {
// 把动态路由参数存放在req的params对象内
/**
路由:/users/:id/name/:name -> /users/123/name/frank
得到如下结果:
match:[
'/users/123/name/frank',
'123',
'frank',
index: 0,
input: '/users/123/name/frank',
groups: undefined
]
keys:[
{ name: 'id', optional: false, offset: 8 },
{ name: 'name', optional: false, offset: 29 }
]
**/
req.params = req.params || {}
keys.forEach((key, index) => {
req.params[key.name] = match[index + 1]
})
}
return match && route.method === method
})
// 如果路由匹配,则响应该路由
if (route) {
return route.handler(req, res)
}
res.end('404')
}
module.exports = Router
到了这一步的MyExpress实现已经触及了Express核心的路由功能,也是Express的设计精华所在,后面我们就要实现路由中间件的功能。
实现MyExpress顶层路由中间件功能
需要实现的功能代码如下:
const express = require('./express')
const app = express()
app.get('/a', (req, res, next) => {
console.log('a 1')
next()
})
app.get('/a', (req, res, next) => {
console.log('a 2')
next()
})
app.get('/a', (req, res, next) => {
res.end('get /a')
})
app.listen(3000, () => {
console.log('http://localhost:3000')
})
访问http://localhost:3000/a 会在终端中打印 a 1 a 2 ,在浏览器上显示get /a
为了实现next() 调用下一个路由的功能和方便后续next参数功能的扩展,我们需要优化的部分是router/index.js 中的路由收集和路由match & handle的实现。
在Express的源码设计中会有一个Layer构造函数的实现,Layer的作用是为路由中间件服务的,在这一步的实现中,可以把每个layer实例看作是每个如下的路由:
app.get('/a', (req, res, next) => {
console.log('a 1')
next()
})
此时的路由模块设计如下:
每个layer实例携带了每个路由重要信息:路由路径path、路由处理handler、匹配得到的动态参数信息keys和params,路由的正则表达式regexp。同时Layer也提供了match原型方法,用于判断请求路径是否匹配路由。
layer.js 在路由模块下,其实现如下:
// route/layer.js
const pathRegexp = require('path-to-regexp')
function Layer (path, handler) {
this.path = path // 路由路径
this.handler = handler // 路由处理
// 动态参数信息
this.keys = []
// 路由的正则表达式
this.regexp = pathRegexp(path, this.keys, {})
// 动态参数信息
this.params = {}
}
// 用于判断当前请求路由是否匹配
Layer.prototype.match = function (pathname) {
const match = this.regexp.exec(pathname)
if (match) {
this.keys.forEach((key, index) => {
this.params[key.name] = match[index + 1]
})
return true
}
return false
}
module.exports = Layer
改造后的router/index.js实现如下:
const url = require('url')
const methods = require('methods')
const Layer = require('./layer')
function Router () {
this.stack = []
}
methods.forEach(method => {
Router.prototype[method] = function (path, handler) {
// 一个路由对应一个layer实例
const layer = new Layer(path, handler)
// 挂在method, 用于匹配
layer.method = method
this.stack.push(layer)
}
})
Router.prototype.handler = function (req, res) {
const { pathname } = url.parse(req.url)
const method = req.method.toLowerCase()
// 当前执行的路由下标
let index = 0
// next的实现
const next = () => {
// 表示已经执行完所有路由
if (index >= this.stack.length) {
return res.end(`Can not get ${pathname}`)
}
// 取出当前执行的路由
const layer = this.stack[index++]
// 匹配请求路径
const match = layer.match(pathname)
// 如果匹配的话,加上动态请求参数
if (match) {
req.params = req.params || {}
Object.assign(req.params, layer.params)
}
// 执行路由handler
if (match && layer.method === method) {
return layer.handler(req, res, next)
}
// 执行下一个路由
next()
}
// 开启应用的路由响应处理
next()
}
module.exports = Router
实现MyExpress单个路由的多个中间件功能
在上面一步实现顶层路由中间件功能的基础之上,我们要在这一步实现单个路由对应多个中间件的功能。需要实现的功能代码如下:
const express = require('./express')
const app = express()
// 单个路由对应多个处理中间件
app.get('/', (req, res, next) => {
console.log('/ 1')
next()
}, (req, res, next) => {
console.log('/ 2')
next()
}, (req, res, next) => {
console.log('/ 3')
next()
})
app.get('/', (req, res, next) => {
res.end('get /')
})
app.listen(3000, () => {
console.log('http://localhost:3000')
})
在上面一步的实现中,我们引入了layer对象,用于优化路由系统的实现。在这一步,根据Express的源码实现,我们还需引入route(!!注意不是router)对象,整体的路由架构实现如下图:
根据上图,router & route & layer 解释如下:
- router路由对象是application持有的
- router对象会收集整个应用的路由信息,内部会持有layer存放在自己的栈中。此时的layer持有route,可以看作app.get这样的一个路由
- route layer 持有路由最终的handler函数,每一个handler都包装成一个layer实例,所有的layer实例都保存在route的stack数组中。所以Route构造函数需要实现一个dispatch原型方法,用于遍历执行当前路由stack中所有的处理函数
router/route.js实现如下(注意注释):
const methods = require('methods')
const Layer = require('./layer')
function Route () {
// 存储handler layer,也就是每个handler
this.stack = []
}
// 遍历执行当前路由对象中所有的处理函数
// out参数含义:当执行完当前所有的handlers,就需要调用下一个app[method]路由(上图中的route layer)
Route.prototype.dispatch = function (req, res, out) {
// 遍历内层的 stack
let index = 0
const method = req.method.toLowerCase()
const next = () => {
if (index >= this.stack.length) return out()
const layer = this.stack[index++]
if (layer.method === method) {
return layer.handler(req, res, next)
}
next()
}
next()
}
methods.forEach(method => {
Route.prototype[method] = function (path, handlers) {
handlers.forEach(handler => {
// 把每个handler对应成每个handler layer,方便路由的match&handle的实现
const layer = new Layer(path, handler)
layer.method = method
this.stack.push(layer)
})
}
})
module.exports = Route
然后为了实现上图的路由架构,我们还要改造一下route/index.js:
- 对于每一个app[method],储存一个layer实例,每个这样的layer持有一个route实例,每个这样的route实例持有多个handler layer。
- 路由模块的handle需要实现链式调用整个应用的路由,具体的,先调用单个app[method]路由的多个中间件,然后调用下一个单个app[method]路由。
route/index.js代码如下(!!注意注释):
const url = require('url')
const methods = require('methods')
const Layer = require('./layer')
const Route = require('./route')
function Router () {
this.stack = []
}
methods.forEach(method => {
Router.prototype[method] = function (path, handlers) {
const route = new Route()
// route layer的handler就是连接触发【app[method]的多个handlers】和【下一个app[method]】,看上图就可以理解
const layer = new Layer(path, route.dispatch.bind(route))
layer.route = route
this.stack.push(layer)
route[method](path, handlers)
}
})
Router.prototype.handle = function (req, res) {
const { pathname } = url.parse(req.url)
let index = 0
const next = () => {
if (index >= this.stack.length) {
return res.end(`Can not get ${pathname}`)
}
const layer = this.stack[index++]
const match = layer.match(pathname)
if (match) {
req.params = req.params || {}
Object.assign(req.params, layer.params)
}
//单个请求路径先判断路由是否匹配,内层handler layer中判定请求方法
if (match) {
// 这里调用的 handler 就是封装过的route的dispatch函数
// 这样做的目的是,可以先调用单个app[method]路由的多个中间件,然后调用下一个单个app[method]路由-> next
return layer.handler(req, res, next)
}
next()
}
next()
}
module.exports = Router
至此,我们就完成了核心的Express 路由模块
实现MyExpress use方法
use方法的调用形式:app.use([path,] callback [, callback…])
- path参数可选,默认是“/” root path
- callback 可以多个也可以单个
如此这般的use方法,我们就可以把它“塞入”router模块中实现。 首先在application中提供use方法:
App.prototype.use = function (path, ...handlers) {
this._router.use(path, handlers)
}
然后在router/index.js中增加:
Router.prototype.use = function (path, handlers) {
// 处理传入的参数
// 如果没有传入path
if (typeof path === 'function') {
handlers.unshift(path) // 处理函数
path = '/' // 默认root path
}
handlers.forEach(handler => {
// 抹平跟app[method]的设计(route layer)
const layer = new Layer(path, handler)
// 标记,用于特殊处理
layer.isUseMiddleware = true
this.stack.push(layer)
})
}
最后修改一下layer.js中的match方法:
Layer.prototype.match = function (pathname) {
const match = this.regexp.exec(pathname)
if (match) {
this.keys.forEach((key, index) => {
this.params[key.name] = match[index + 1]
})
return true
}
// 匹配 use 中间件的路径处理
if (this.isUseMiddleware) {
if (this.path === '/') {
return true
}
if (pathname.startsWith(`${this.path}/`)) {
return true
}
}
return false
}
完成use方法的实现后,我们的MyExpress就完成了模拟Express中间件的核心设计。
总结
实现完MyExpress,我们可以学习到Express的核心就是router模块。app只是设计为顶层结构,对外提供use、listen 和 多个http method方法等。Express的中间件是有调用顺序的,也是可以中断调用的。中间件天然的面向切片的设计和路由系统的分层设计融合,真正使Express成为了高度包容、快速而极简的 Node.js Web 框架.