ExpressJs中间件原理学习

668 阅读7分钟

文章首发于我的个人博客 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需要如下功能:

  1. MyExpress的Application构造函数,并且该构造函数需要提供:
    • listen原型方法,用于监听端口
    • get 原型方法,用于收集和响应get路由
  2. 创建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中的路由功能,实现一个独立的路由模块,其主要功能有:

  1. 处理不同的请求方法。
  2. 处理匹配不同的请求路径。
  3. 处理动态路由路径参数。

处理第一个功能点,我们需要用到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.png

每个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)对象,整体的路由架构实现如下图:

express路由架构图.png

根据上图,router & route & layer 解释如下:

  1. router路由对象是application持有的
  2. router对象会收集整个应用的路由信息,内部会持有layer存放在自己的栈中。此时的layer持有route,可以看作app.get这样的一个路由
  3. 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:

  1. 对于每一个app[method],储存一个layer实例,每个这样的layer持有一个route实例,每个这样的route实例持有多个handler layer。
  2. 路由模块的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 框架.