Fed dead && Rd won:express源码解析

763 阅读8分钟

2022年的JYM都一致的认为2023年会更好,等来的却是"前端已死,后端当立",这到底是有人故意而为之传播焦虑,还是互联网的彻底崩盘。我相信ChatGPT会给出最好的答案。

进入主题~

今天来聊一聊Express的源码:

express()

var express = require("express");
var app = express();

源码位置在/lib/express.js,调用express函数,实际上是调用的函数createApplication, 我们来分析分析到底发生了什么!

function createApplication() {
 (1var app = function(req, res, next) {
    app.handle(req, res, next);
  };
 (2mixin(app, EventEmitter.prototype, false);
 (3mixin(app, proto, false);
 (4) app.request = Object.create(req, {
    app: { configurable: true, enumerable: true, writable: true, value: app }
  })
 (5)app.response = Object.create(res, {
    app: { configurable: true, enumerable: true, writable: true, value: app }
  })
 (6)app.init();
  return app;
}

通过表达式(1)定义了app函数,借用merge-descriptors库(2)将NodeJS.EventEmitter原型上的方法merge到app的prototype:

proto:(application)(3)定义了express中所有的方法,也就是说proto里面有什么方法 app里面就有什么方法,同理app也是通过对应的库继承proto的方法。

当我们app.listen()监听(2)服务器启动的时候本质上是调用application中的listen函数,其内部又通过http.createServer创建服务返回listen监听函数

app.listen = function listen() {
  var server = http.createServer(function(req,res,next) {
    ...
  }); 
  return server.listen.apply(server, arguments);
};

然后通过Object.create(4,5)将app作为request和response的属性值

最后初始化(6)默认配置defaultConfiguration

先打断一下 ~

先看看app.init都做了些什么(摘取部分代码逐一解释)

app.init

初始化默认配置

 var env = process.env.NODE_ENV || 'development';

  // 默认配置
  this.enable('x-powered-by');
  this.set('etag', 'weak');
  this.set('env', env);
  this.set('query parser', 'extended');
  this.set('subdomain offset', 2);
  this.set('trust proxy', false);

express默认开启x-powered-by打开开发工具并检查响应头,你会注意到它可以明确告诉客户端当前服务器是由Express开发的,这是非常危险的!!! , 当攻击者深入的理解当前框架 ,并且知道相关漏洞,会有很严重的后果,因此我们需要设置app.disable('x-powered-by')

ETag协商缓存必知必会,默认开启弱校验

queryParser用来解析传入的query参数,贴一段代码,清晰易懂:

exports.compileQueryParser = function compileQueryParser(val) {
  var fn;

  if (typeof val === 'function') {
    return val;
  }
  switch (val) {
    case true:
      fn = querystring.parse;
      break;
    case false:
      fn = newObject;
      break;
    case 'extended':
      fn = parseExtendedQueryString;
      break;
    case 'simple':
      fn = querystring.parse;
      break;
    default:
      throw new TypeError('unknown value for query parser function: ' + val);
  }

  return fn;
}
function parseExtendedQueryString(str) {
  return qs.parse(str, {
    allowPrototypes: true
  });
}

querystringqs

trust proxy:详细见文档 , 这个很难理解, 附上两篇 stack overflow的答疑:

NodeJS, Express, why should I use app.enable('trust proxy');

Using app.set to set trust proxy

监听子应用

this.on('mount', function onmount(parent) {
    if (this.settings[trustProxyDefaultSymbol] === true
      && typeof parent.settings['trust proxy fn'] === 'function') {
      delete this.settings['trust proxy'];
      delete this.settings['trust proxy fn'];
    }
    setPrototypeOf(this.request, parent.request)
    setPrototypeOf(this.response, parent.response)
    setPrototypeOf(this.engines, parent.engines)
    setPrototypeOf(this.settings, parent.settings)
  });

主要是继承父应用的配置

其他配置

  this.locals = Object.create(null);
  this.mountpath = '/';
  this.locals.settings = this.settings;
  this.set('view', View);
  this.set('views', resolve('views'));
  this.set('jsonp callback name', 'callback');

  if (env === 'production') {
    this.enable('view cache');
  }

主要是保存基础配置settings, 该配置将会在renderAPI中用到,其次是设置项目中视图目录或目录数组,常见的在开发中常见的配置如下:

app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");

jsonp callback name, 很明显指定默认的 JSONP 回调名称。如下:

// ?callback=foo
res.jsonp({ user: 'tobi' })
// => foo({ "user": "tobi" })

app.set('jsonp callback name', 'cb')

// ?cb=foo
res.status(500).jsonp({ error: 'message' })
// => foo({ "error": "message" })

这就是执行express函数的整体过程

按照编码习惯,加下来看app.use发生了什么

app.use

注册中间件

var cors = require("cors")
app.use(cors())

根据传参执行对应的代码逻辑 除了上面的传入整个函数作为中间件, 还可以通过(paths,cb)形式匹配路由。

app.use = function use(fn) {
  var offset = 0;
  var path = '/';
  if (typeof fn !== 'function') {
    var arg = fn;
    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }
    if (typeof arg !== 'function') {
      offset = 1;
      path = fn;
    }
  }
  var fns = flatten(slice.call(arguments, offset)); //数组扁平
    ...
  // app.use -> router.use()
 
  fns.forEach(function (fn) {
    if (!fn || !fn.handle || !fn.set) {
      return router.use(path, fn);
    }
    fn.mountpath = path;
    fn.parent = this;
   (7) router.use(path, function mounted_app(req, res, next) {
      var orig = req.app;
      fn.handle(req, res, function (err) {
        setPrototypeOf(req, orig.request)
        setPrototypeOf(res, orig.response)
        next(err);
      });
    });
    fn.emit('mount', this);
  }, this);

  return this;
};

如果参数形式是路由,则取出第一个参数,此时偏移量为1,数组扁平化得到截取path之外的fn数组,循环遍历对当前fns, 发现内部调用的是router.use()

了解真相才是真正的自由: app.use()内部实际调用的是router.use()

代码(7)还是存在疑惑,fn.handle到底指什么呢?我们在router.use最后一节末尾详细说明~

router.use

// app.use 省略代码
 this.lazyrouter();//主路由
  var router = this._router;
app.lazyrouter = function lazyrouter() {
  if (!this._router) {
    this._router = new Router({
      caseSensitive: this.enabled('case sensitive routing'),
      strict: this.enabled('strict routing')
    });

    this._router.use(query(this.get('query parser fn')));
    this._router.use(middleware.init(this));
  }
};

这里有一个问题,为什么不在初始化的函数中直接生成一个默认路由呢?

官方是这样解释的: 如果尚未添加基本路由器,则延迟添加它。我们无法在defaultConfiguration中添加基本路由器,因为它读取的应用程序设置可能在运行后设置。

原因在于设置路由的相关参数需要调用app.set方法,这个方法明显需要有app实例,如果在获取app实例的时候就初始化了一个路由,这个路由的参数就没办法配置了。因此,在获取app实例后,必须先对路由参数进行配置,然后再调用对应的app.use等方法。

需要注意的是该过程中其实是利用了单例模式,保证整个过程中获取router实例的唯一性。

Router

var router = function(options) {
  var opts = options || {};
  function router(req, res, next) {
    router.handle(req, res, next);
  }
  setPrototypeOf(router, proto)
  ...
  return router;
};

实例化Router,主要做的就是proto作为函数的原型对象,因此可以访问proto的任何方法,即router.handle是proto.handle。

示例代码:

var router = express.Router();
router.use("/video",require('./video'))

//video.js
router
  .get("/videolist", verifyToken(), videoController.videolist)
  .get("/detail/:videoId", verifyToken(), videoController.video)

下面的讲解, 按照上面的代码依次展开。

回到router.use(proto.use)。

router.use类似于 app.use()。

router.use源码中传参的判断逻辑和app.use是一样的:

...
 if (typeof fn !== 'function') {
    var arg = fn;
    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }
    if (typeof arg !== 'function') {
      offset = 1;
      path = fn;
    }
  }
  ...

与之不同的是对中间件通过Layer“包裹”,添加到堆栈中,主要源码如下:

...

   var layer = new Layer(path, {  //路径 , 路由配置 ,  fn
      sensitive: this.caseSensitive,
      strict: false,
      end: false
    }, fn);
    layer.route = undefined;
    this.stack.push(layer); //也就是中间件的回调函数都放到了 this.stack 数组中
...

我们来分析分析Layer到底做了什么,以及对stack的运用。

Layer

layer源码

判断Layer是否实例化的形式调用 , 对use中的参数(fn,中间件的回调函数)保存在Layer实例中,通过path-to-regexp库对path进行解析,最后是对特殊路由进行检验

其实Layer就是对中间件进行了一层包装

Layer函数原型上的方法有很多,在这里不一一列举,整个执行流程走通后,在回过头看

router.*

接着, 是router.get等函数的解析:

当你阅读源码route.js的时候发现未对get、post等method进行单独定义,仅仅有all方法,使用了 methods库:

method

最后在index.js中将methods.concat('all')挂载到proto上,调用route方法

route 到这里逻辑非常容易混乱,那么到底router.get("/videolist", verifyToken(), videoController.videolist) 发生了什么?

通过forEach,将所有的method添加到proto,每次调用router.* 时,实际上是通过proto.route(router.route)获取Route实例,参数是传入的路径,返回的是当前路径所有对应的路由,每次注册路由, 都会创建特殊Layer(第三个参数方便在后续中间件的执行,见下节),并且将当前路由添加到堆栈中,route[method]其实是调用Route实例中methods方法(内部也是对methods进行遍历) ,最终通过apply 将当前路由中的所有中间件作为参数调用get( * ) 方法,上面提到过Route实例中也有单独的中间件调用栈stack,因此stack存放的就是一个路由中所有的中间件。

Router.stack vs Route.stack

Router和Route的stack是有差别的,这个差别主要体现在存放的layer(layer是用来封装中间件的一个数据结构)不太一样,

stack

Router.stack中存放的中间件包括但不限于路由中间件,而只有路由中间件的执行才会依赖与请求method,因此Router.stack里的layer没有method属性,而是将其动态添加(layer的定义中没有method字段)到了Route.stack的layer中;layer.route字段也是动态添加的,可以通过该字段来判断中间件是否是路由中间件。

最后在回到app.use,上面的疑问(7)fn.handle,翻遍了源码,都没有找到对中间件定义的handle函数,应该是针对的第三方express 的应用,就是指有 handle 的应用,每次都把属性复制过来,就是怕第三方的把属性给改了,之后让框架崩溃

到这里其实内部的整体实现逻辑已经介绍的完了,回到最前面app.handle

app.handle

在初始化express时,app.handle并没有调用。

app.listen创建服务器完成之后调用app.handle,获取所有的stack(中间件/路由)。

// 部分核心代码
proto.handle = function handle(req, res, out) {
  var self = this;
  var idx = 0;
  var stack = self.stack;
  ...
  next();
  function next(err) {
    var layerError = err === 'route'
      ? null
      : err;
    
    if (idx >= stack.length) {
      return;
    }
    var path = getPathname(req);
 
    // find next matching layer
    var layer;
    var match;
    var route;
    while (match !== true && idx < stack.length) {
      layer = stack[idx++];
      match = matchLayer(layer, path);
      route = layer.route;
 
      if (match !== true) {
        continue;
      }
    }
    // no match
    if (match !== true) {
      return done(layerError);
    }
 
    // this should be done for the layer
    self.process_params(layer, paramcalled, req, res, function (err) {
      if (err) {
        return next(layerError || err);
      }
      if (route) {
        return layer.handle_request(req, res, next);
      }
 
      trim_prefix(layer, layerError, layerPath, path);
    });
  }
 
  function trim_prefix(layer, layerError, layerPath, path) {
 
    if (layerError) {
      layer.handle_error(layerError, req, res, next);
    } else {
      layer.handle_request(req, res, next);
    }
  }
};

执行app.handle才正式处理中间件执行逻辑,其利用「闭包的特性」缓存 idx 来记录当前遍历的状态 , 该方法内部又实现了 next 方法来匹配当前需要执行的中间件,主动调用next函数,通过while循环取出取出对应的路由,调用layer.handle_request调用fn即当前中间件,从遍历的代码可以明白中间件注册的顺序是非常重要的

Layer.prototype.handle_request = function handle(req, res, next) { //本质上是调用fn
  var fn = this.handle; //当前的中间件
  if (fn.length > 3) {
    // not a standard request handler
    return next();
  }
  try {
   (8) fn(req, res, next);//当前的中间件 传递三个参数
    // 当我们调用 ↓
    app.use((req,res,next) => {
            next() //如果调用next实际上是调用的lrouter/index.js/ 176:next(err) 函数  因为此时idx 已经++了 所以会匹配下一个中间件 
    })
  } catch (err) {
    next(err);
  }
};

上面代码中的 next 其实是handle函数中next函数,内部通过判断函数的形参个数过滤掉非错误处理中间件,app.use开始对下一个中间件进行入栈操作,一直重复下去,直到 idx >= stack.length

handle 方法初始化执行了一次 next 方法,但是该方法每次调用最多只能匹配一个中间件,所以在执行 handle_request 方法时,会将 next 方法透传给中间件(8),这样开发者就可以通过手动调用 next 方法的方式来执行接下来的中间件。

致谢

如何在 Express4.x 中愉快地使用 async

从express源码中探析其路由机制