Express中间件原理解析与实现

前言

express是node.js最常用的web server应用框架。 框架就是遵循一定规则的代码架子,它有两个特点:

  • 封装API,让开发者更关注于业务代码的开发
  • 有一定的流程和标准

express框架的核心特性:

  • 可以设置中间件来响应 HTTP 请求
  • 定义了路由表用于执行不同的 HTTP 请求动作
  • 可以通过向模板传递参数来动态渲染 HTML 页面

本文通过实现一个简单的Express类,来浅析express如何实现中间注册、next机制和路由处理。

express功能分析

首先通过两个express代码示例来分析一下express提供的功能:

express官网Hello world示例

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

入口文件app.js分析

以下是通过express-gernerator脚手架生成的express项目的入口文件app.js的代码:

// 处理路由不匹配的错误
var createError = require('http-errors');
var express = require('express');
var path = require('path');
// 记录日志
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

// app是一个express实例
var app = express();

// view engine setup 注册视图引擎
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// 解析post请求的json格式,给req加body字段
app.use(express.json());
// 解析post请求的urlencoded格式,给req加body字段
app.use(express.urlencoded({
  extended: false
}));
// 静态文件处理
app.use(express.static(path.join(__dirname, 'public')));

// 注册父级路由
app.use('/', indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  // 开发环境抛错,生产环境不抛错
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

从以上两段代码可以看出,express实例app主要有3个核心方法:

  1. app.use([path,] callback [, callback...]) 注册中间件,当所请求路径的基数匹配时,将执行中间件函数。
    • path:调用中间件功能的路径
    • callback:回调函数,可以是:
      • 单个中间件函数
      • 一系列中间件函数(以逗号分隔)
      • 中间件函数数组
      • 以上所有的组合
  2. app.get()app.post()use()方法类似,都是实现中间件的注册,只不过与http请求进行了绑定,只有使用了相应的http请求方法才会触发中间件注册
  3. app.listen() 创建httpServer,传递server.listen()需要的参数
代码实现

基于以上express代码的功能分析,可以看出express的实现有三个关键点:

  • 中间件函数的注册
  • 中间件函数中核心的next机制
  • 路由处理,主要是路径匹配
    基于以上关键点,下面实现一个简易的LikeExpress类。

1、类的框架

先确定这个类要实现的主要方法:

  • use():实现通用的中间件注册
  • get()post():实现与http请求相关的中间件注册
  • listen():实际上就是httpServer的listen()函数,所以在这个类的listen()函数里创建httpServer,透传server参数,监听请求,并执行回调函数(req, res) => {} 复习一下node原生的httpServer的使用:
    const http = require("http");
    const server = http.createServer((req, res) => {
        res.end("hello");
    });
    server.listen(3003, "127.0.0.1", () => {
        console.log("node服务启动成功了");
    })
    

    所以LikeExpress类基本框架如下:

const http = require('http');

class LikeExpress {
  constructor() {}

  use() {}

  get() {}

  post() {}

  // httpServer回调函数
  callback() {
    return (req, res) => {
      res.json = (data) => {
        res.setHeader('content-type', 'application/json');
        res.end(JSON.stringify(data));
      };
    }
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    // 参数透传到httpServer里
    server.listen(...args);
  }
}

module.exports = () => {
  return new LikeExpress();
}

2、中间件注册

app.use([path,] callback [, callback...]) 可以看出,中间件可以是函数数组,也可以是单个函数,这里为了简化实现,统一将中间件处理为函数数组。 而LikeExpress类可以实现中间件注册的方法有:use()get()post() 以上3个方法都可以实现中间件的注册,只是请求方法的不同,触发的中间件不一样。 因此可以考虑:

  • 抽象出通用的中间件注册函数。
  • 为这三个方法建立3个中间件函数数组,存放不同请求的中间件。use()是所有请求通用的中间件注册方法,因此存放use()中间件的数组是get()post()的并集。

(1)中间件队列数组

中间件数组需要存放在公用的地方,以便类里的方法都能读取到这些中间件,所以考虑将中间件数组放在constructor()构造函数中。

constructor() {
  // 存放中间件的列表
  this.routes = {
    all: [],// 通用的中间件
    get: [],// get请求的中间件
    post: [],// post请求的中间件
  };
}

(2)中间件注册函数

所谓的中间件注册,即把中间件存入相应的中间件数组中。中间件注册函数需要解析传入的参数,第一个参数可能是路由,也可能是中间件,所以需要判断第一个参数是不是路由,如果是路由,则将路由原样输出;否则默认是根路由。再将剩余中间件参数转换为数组。

register(path) {
  const info = {};
  // 如果第一个参数是路由
  if (typeof path === "string") {
    info.path = path;
    // 从第二个参数开始,转换为数组,存入中间件数组中
    info.stack = Array.prototype.slice.call(arguments, 1); // 取出第二个参数
  } else {
    // 如果第一个参数不是路由,则默认是根路由,则全部路由都会执行
    info.path = '/';
    // 从第一个参数开始,转换为数组,存入中间件数组中
    info.stack = slice.call(arguments, 0);
  }
  return info;
}

(3)use()get()post() 实现

有了通用的中间件注册函数register(),就可以基于register()轻松实现 use()get()post(),将中间件存入相应的中间件数组中:

use() {
  const info = this.register.apply(this, arguments);
  this.routes.all.push(info);
}

get() {
  const info = this.register.apply(this, arguments);
  this.routes.get.push(info);
}

post() {
  const info = this.register.apply(this, arguments);
  this.routes.post.push(info);
}

3、路由匹配处理

当注册函数中第一个参数为路由时,只有当请求路径与路由匹配或者是它的子路由时,才会触发相应的中间件函数。 所以需要一个路由匹配函数,根据请求方法和请求路径,取出匹配路由的中间件数组,供后续的callback()去执行:

match(method, url) {
  let stack = [];
  // 浏览器自带的icon请求,忽略
  if (url === "/favicon") {
    return stack;
  }

  // 获取routes
  let curRoutes = [];
  curRoutes = curRoutes.concat(this.routes.all);// use()会在所有路由执行
  curRoutes = curRoutes.concat(this.routes[method]); // 根据请求方法获取对应路由
  curRoutes.forEach(route => {
    if (url.indexOf(route.path) === 0) {
      // 判断是否属于当前路由或子路由,如果是,则取出
      stack = stack.concat(route.stack);
    }
  })
  return stack;
}

然后在httpServer的回调函数callback()中取出需要执行的中间件:

callback() {
  return (req, res) => {
    res.json = (data) => {
      res.setHeader('content-type', 'application/json');
      res.end(JSON.stringify(data));
    };
    // 根据请求方法和路径,区分哪些中间件函数需要执行
    const url = req.url;
    const method = req.method.toLowerCase();
    const resultList = this.match(method, url);
    // handle是核心的next机制,接下来会讲
    this.handle(req, res, resultList);
  }
}

4、next机制实现

express的中间件函数参数是:req, res, nextnext参数是一个函数,只有调用它才可以使中间件函数一个一个按照顺序执行下去,与ES6的Generator中的next()类似。 而回到我们的实现上,其实就是要实现一个next()函数,这个函数需要:

  1. 从中间件队列数组里每次按次序取出一个中间件
  2. next()函数传入到取出的中间件中。由于中间件数组是公用的,每次执行next(),都会从中间件数组中取出第一个中间件函数执行,从而实现了中间件按次序的效果
// 核心的next机制
handle(req, res, stack) {
  const next = () => {
    // 中间件队列出队,拿到第一个匹配的中间件,stack数组是同一个,所以每执行一次next(),都会取出下一个中间件
    const middleware = stack.shift();
    if (middleware) {
      // 执行中间件函数
      middleware(req, res, next);
    }
  }
  // 立马执行
  next();
}
测试

为了验证上述LikeExpress类是否实现了中间件注册、路由匹配以及next机制的功能,用一段代码验证:

const express = require('./like-express');

const app = express();
// 1
app.use((req, res, next) => {
  console.log('请求开始...', req.method, req.url);
  next();
})
// 2
app.use((req, res, next) => {
  console.log('处理cookie...');
  req.cookie = {
    useId: "test"
  };
  next();
})
// 3
app.use('/api', (req, res, next) => {
  console.log('处理/api路由');
  next();
})
// 4
app.get('/api', (req, res, next) => {
  console.log('get /api路由');
  next();
})

app.listen(7000, () => {
  // console.log('server is running at 7000');
})
  1. 访问http://localhost:7000/,预期结果应该是执行了1和2,实际结果:
  2. 访问http://localhost:7000/api,预期结果应该是1234都执行了,实际结果:

可以看到,实际结果与预期结果相同,证明我们的实现是正确的。

完整代码
const http = require('http');
const slice = Array.prototype.slice;

class LikeExpress {
  constructor() {
    // 存放中间件的列表
    this.routes = {
      all: [],
      get: [],
      post: [],
    };
  }

  register(path) {
    const info = {};
    // 如果第一个参数是路由
    if (typeof path === "string") {
      info.path = path;
      // 从第二个参数开始,转换为数组,存入stack
      info.stack = slice.call(arguments, 1); // 取出第二个参数
    } else {
      // 如果第一个参数不是路由,则默认是根路由,则全部路由都会执行
      info.path = '/';
      // 从第一个参数开始,转换为数组,存入stack
      info.stack = slice.call(arguments, 0);
    }
    return info;
  }

  use() {
    const info = this.register.apply(this, arguments);
    this.routes.all.push(info);
  }

  get() {
    const info = this.register.apply(this, arguments);
    this.routes.get.push(info);
  }

  post() {
    const info = this.register.apply(this, arguments);
    this.routes.post.push(info);
  }

  match(method, url) {
    let stack = [];
    // 浏览器自带的icon请求
    if (url === "/favicon") {
      return stack;
    }

    // 获取routes
    let curRoutes = [];
    curRoutes = curRoutes.concat(this.routes.all);
    curRoutes = curRoutes.concat(this.routes[method]); // 根据请求方法获取对应路由
    curRoutes.forEach(route => {
      if (url.indexOf(route.path) === 0) {
        // 判断是否属于当前路由或字路由
        stack = stack.concat(route.stack);
      }
    })
    return stack;
  }

  // 核心的next机制
  handle(req, res, stack) {
    const next = () => {
      // 拿到第一个匹配的中间件
      const middleware = stack.shift();
      if (middleware) {
        // 执行中间件函数
        middleware(req, res, next);
      }
    }
    // 立马执行
    next();
  }

  callback() {
    return (req, res) => {
      res.json = (data) => {
        res.setHeader('content-type', 'application/json');
        res.end(JSON.stringify(data));
      };
      const url = req.url;
      const method = req.method.toLowerCase();
      // 根据方法区分哪些函数需要执行
      const resultList = this.match(method, url);
      this.handle(req, res, resultList);
    }
  }

  listen(...args) {
    const server = http.createServer(this.callback());
    server.listen(...args);
  }
}

module.exports = () => {
  return new LikeExpress();
}
原文链接:juejin.im

上一篇:Vue项目嵌入编辑器的坑和前端工程化解决方案
下一篇:koa2中间件的错误捕捉与async/await本质

相关推荐

  • 🙋Hanjst汉吉斯特改进+enSafeExpression安全表达式等

    Hanjst汉吉斯特模版语言及模版引擎,近期持续改进升级。 这次改进主要是增加了对安全输出表达式兼容,由于涉及到对软件开发过程中的效率和软件运行效率的平衡和取舍,所以多写了几句,以描述这个权衡利弊对...

    5 个月前
  • 项目总结 - 构建vue cli3.0+express项目

    简介:本篇是记录搭建流程,不过多叙述,搭建的细节,主要以前端为主,项目是主要是为了重构前端代码,后端代码完全复用,还会有篇主要讲node项目的搭建 项目背景: 一个客服项目,原来是react+expr...

    2 年前
  • 面试还问redux?那我从头手撸源码吧(中间件篇)

    昨天的文章手写了一版redux的核心源码,redux库除了数据的状态管理还有一块重要的内容那就是中间件,今天我还是尝试将此部分源码完成。 中间件 react中管理数据的流程是单向的,就是说,从派发动作...

    2 年前
  • 通过编写一个路由中间件来学习 Koa

    混了四年的大学生活结束了,校招没有找到工作的我还面临着失业。没办法,只有临时抱抱佛脚看看能不能找个工作了。据说最近前端圈里不会 NodeJs 是不可能找到工作的,于是抱起了 NodeJs 里比较流行的...

    2 年前
  • 通过变量来使用next()在expressjs未来中间件

    cchamberlainuser2791897提出了一个问题:passing variables to the next middleware using next() in expressjs,或许...

    3 年前
  • 详解redux中间件

    关于redux中间件是什么以及为什么需要redux中间件的话题,网上有太多的文章已经介绍过了,本文就不再赘述了。如果你有类似的困惑:redux中间件究竟是如何作用于dispatch?redux的源码和...

    2 个月前
  • 简单介绍redux的中间件

    用过react的同学都知道在redux的存在,redux就是一种前端用来存储数据的仓库,并对改仓库进行增删改查操作的一种框架,它不仅仅适用于react,也使用于其他前端框架。

    3 年前
  • 笔记:解读express 4.x源码

    此为裁剪过的笔记版本。 原文在此:https://segmentfault.com/a/11... 感谢@YiQi 带来的好文章。我稍作修改,只是为了更加清晰一点点。

    3 年前
  • 理解 Koa 的中间件机制

    中间件概念在编程中使用广泛, 不管是前端还是后端, 在实际编程中或者框架设计都有使用到这种实用的模型, 下面我们就来谈谈它的作用. 面向切面编程(AOP) 相信很多人都听过所谓的 AOP 编程或者面...

    3 年前
  • 玩转Express(一)实战开发

    前言 作为前端工程师的我们,经常想打破前端的次元壁(不想只是写页面调接口辣),想去学习一门后端语言,建立起自己的服务,往全栈方向冲冲冲。那么个人觉得,没有比 Node.js 更合适我们的了吧。

    8 天前

官方社区

扫码加入 JavaScript 社区