Connect 源码深度解析

0 阅读8分钟

版本:3.7.0

核心文件:index.js(约 270 行)

一、项目概览

connect 是一个极简的 Node.js HTTP 中间件框架,也是 Express.js 的前身。

核心设计思想:将 HTTP 请求依次传递给一个中间件栈(stack),每个中间件决定是处理请求还是调用 next() 传递给下一个。

依赖关系

依赖包作用
debug开发调试日志
finalhandler兜底的 404/500 响应处理器
parseurl带缓存的 URL 解析(性能优化)
utils-merge将对象属性混入另一个对象

二、整体架构

数据流全景

http.createServer(app)
       │
       ▼ req, res
  app(req, res, next)          ← app 本质是一个函数
       │
       ▼
  app.handle(req, res)
       │
  ┌────▼────────────────────────────────────┐
  │  stack = [                              │  │    { route: '/',     handle: fn1 },     │  │    { route: '/api',  handle: fn2 },     │  │    { route: '/',     handle: errorFn }, │  ← 4 个参数 = 错误中间件  │  ]                                      │
  └─────────────────────────────────────────┘
       │
  next() → 路径匹配? → call(handle) → 中间件执行
       ↑                                    │
       └──────── 调用 next() ───────────────┘
                                            │ 栈走完
                                            ▼
                                      finalhandler (404/500)

核心函数一览

函数类型作用
createServer()public工厂函数,创建 app 实例
proto.use()public注册中间件,存入 stack
proto.handle()private请求分发引擎,驱动 next 循环
proto.listen()public语法糖,启动 HTTP 服务器
call()private区分普通/错误中间件并调用
getProtohost()private提取完整 URL 的协议+主机部分
logerror()private非 test 环境下打印错误堆栈

三、createServer() —— 工厂函数

function createServer() {
  function app(req, res, next){ app.handle(req, res, next); }
  merge(app, proto);
  merge(app, EventEmitter.prototype);
  app.route = '/';
  app.stack = [];
  return app;
}

关键设计点

  1. app 是一个函数:签名为 (req, res, next),因此:
  • 可以直接传给 http.createServer(app) 作为请求回调
  • 也可以作为子中间件挂载到其他 app 里(parentApp.use('/sub', app)
  1. 函数也是对象:通过 merge(app, proto) 把方法混入函数对象,让 app 同时拥有 use()handle()listen() 方法
  2. EventEmitter:通过 merge(app, EventEmitter.prototype) 赋予事件发射能力,测试中可以 app.on('foo', cb) / app.emit('foo')
  3. app.stack = [] :核心数据结构,存储所有中间件,每个元素格式为 { route: string, handle: function }

四、proto.use() —— 注册中间件

proto.use = function use(route, fn) {
  var handle = fn;
  var path = route;

  // route 省略时默认 '/'
  if (typeof route !== 'string') {
    handle = route;
    path = '/';
  }

  // 包装子 connect app
  if (typeof handle.handle === 'function') {
    var server = handle;
    server.route = path;
    handle = function (req, res, next) {
      server.handle(req, res, next);
    };
  }

  // 包装原生 http.Server
  if (handle instanceof http.Server) {
    handle = handle.listeners('request')[0];
  }

  // 去掉末尾 /
  if (path[path.length - 1] === '/') {
    path = path.slice(0, -1);
  }

  this.stack.push({ route: path, handle: handle });
  return this; // 支持链式调用
};

支持三种中间件类型

传入类型处理方式
普通函数 function(req, res, next)直接存入 stack
connect app(有 .handle 方法)包一层函数,把父 next 传进去
原生 http.Server取其 request 事件的第一个监听器

子 app 挂载的关键

// 包装时更新子 app 的 route 属性
server.route = path;

// 包装函数传入外层的 next ← 关键!
handle = function (req, res, next) {
  server.handle(req, res, next);
};

子 app 走完自己的 stack 时,会通过这个 next 回到父 app 继续执行。子 app 完全不需要知道父 app 的存在。

五、proto.handle() —— 请求分发引擎

完整代码解析

proto.handle = function handle(req, res, out) {
  var index = 0;
  var protohost = getProtohost(req.url) || '';  // 处理绝对 URL
  var removed = '';       // 被裁掉的路径前缀(挂载时用)
  var slashAdded = false; // 是否人工补了前导 /
  var stack = this.stack;

  // 兜底处理器:如果 out 未提供,使用 finalhandler(处理 404/500)
  var done = out || finalhandler(req, res, {
    env: env,
    onerror: logerror
  });

  // 保存原始 URL,只赋值一次
  req.originalUrl = req.originalUrl || req.url;

  function next(err) {
    // ① 还原被补的前导 /
    if (slashAdded) {
      req.url = req.url.substr(1);
      slashAdded = false;
    }

    // ② 还原被裁掉的路径前缀
    if (removed.length !== 0) {
      req.url = protohost + removed + req.url.substr(protohost.length);
      removed = '';
    }

    // ③ 取下一个中间件
    var layer = stack[index++];

    // ④ 栈走完,异步调用兜底处理器
    if (!layer) {
      defer(done, err);
      return;
    }

    // ⑤ 路径匹配检查(见下方详解)
    var path = parseUrl(req).pathname || '/';
    var route = layer.route;

    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
      return next(err);
    }

    var c = path.length > route.length && path[route.length];
    if (c && c !== '/' && c !== '.') {
      return next(err);
    }

    // ⑥ URL 裁剪(挂载子路径时)
    if (route.length !== 0 && route !== '/') {
      removed = route;
      req.url = protohost + req.url.substr(protohost.length + removed.length);

      if (!protohost && req.url[0] !== '/') {
        req.url = '/' + req.url;
        slashAdded = true;
      }
    }

    // ⑦ 调用中间件
    call(layer.handle, route, err, req, res, next);
  }

  next(); // 启动分发
};

六、路径匹配逻辑(详解)

两条规则

// 规则1:前缀匹配(大小写不敏感)
if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
  return next(err);
}

// 规则2:匹配结束位置必须是边界字符 '/''.',或者正好到末尾
var c = path.length > route.length && path[route.length];
if (c && c !== '/' && c !== '.') {
  return next(err);
}

测试用例对照

请求路径挂载路由结果原因
/blog/blog✅ 匹配前缀完全相等
/blog/post/1/blog✅ 匹配边界字符是 /
/blog.json/blog✅ 匹配边界字符是 .
/BLog/blog✅ 匹配toLowerCase() 大小写不敏感
/blog-o-rama/blog❌ 不匹配边界字符是 -,非 /.
/blog-o-rama/article/blog❌ 不匹配同上

规则2 的意义:防止 /blog 路由错误地匹配 /blog-o-rama,即路径组件必须完整匹配。

七、URL 重写与还原(挂载子路径)

三个关键变量的作用

变量类型作用
protohoststring协议+主机(如 http://example.com),绝对 URL 场景下使用
removedstring被裁掉的路径前缀,next() 时还原
slashAddedboolean是否人工补了前导 /next() 时还原

完整流程示例

普通相对路径:

app.use('/blog', middleware)
请求:GET /blog/post/1

进入中间件前:
  req.url = '/blog/post/1'

裁剪(removed = '/blog'):
  req.url = '/post/1'          ← 子中间件看到的是相对路径

middleware 内部调用 next():
  还原 removed:
  req.url = '/blog/post/1'     ← 下一个中间件看到完整路径

req.originalUrl 始终是 '/blog/post/1'(不变)

绝对 URL(FQDN)场景:

请求:GET http://example.com/blog/post/1

protohost = 'http://example.com'

裁剪:
  req.url = 'http://example.com' + '/post/1'
           = 'http://example.com/post/1'   ← 协议+主机保留,只裁路径部分

/ 场景:

app.use('/blog', middleware)
请求:GET /blog   (匹配后路径变为空字符串)

裁剪后:req.url = ''
补 /:  req.url = '/'    slashAdded = true

next() 时还原:req.url = ''(去掉补的 /)
再还原 removed:req.url = '/blog'

req.originalUrl 的保护机制

req.originalUrl = req.originalUrl || req.url;

使用 || 而非 =,保证只在第一次进入时赋值,之后任何 URL 重写都不会改变它。

八、错误处理流程

两种错误触发方式

// 方式1:同步抛出(call() 里 try/catch 捕获)
app.use(function(req, res, next) {
  throw new Error('boom!');
});

// 方式2:主动传递
app.use(function(req, res, next) {
  next(new Error('boom!'));
});

call() 函数 —— 区分中间件类型

function call(handle, route, err, req, res, next) {
  var arity = handle.length;   // 函数参数个数是判断依据
  var error = err;
  var hasError = Boolean(err);

  try {
    if (hasError && arity === 4) {
      handle(err, req, res, next);   // 错误中间件
      return;
    } else if (!hasError && arity < 4) {
      handle(req, res, next);        // 普通中间件
      return;
    }
    // arity === 4 但无错误 → 跳过
    // arity < 4  但有错误 → 跳过
    // arity > 4           → 永远跳过(两个条件都不满足)
  } catch (e) {
    error = e;   // 同步异常转为错误传播
  }

  next(error);
}

错误传播路径图

next(err) 触发
    │
    ▼ 遍历 stack
普通中间件 (arity < 4)   → 有 err 时全部跳过
    │
    ▼
错误中间件 (arity === 4) → 接收并处理 err
    │
    ├─ 调用 next()        → 错误清除,继续走普通中间件
    └─ 调用 next(err)     → 错误继续向后传递(可以链式多个错误中间件)
    │
    ▼ 所有中间件走完
finalhandler             → 输出 500 响应

关键行为总结

场景行为
有错误 + 普通中间件 (arity < 4)跳过
无错误 + 错误中间件 (arity === 4)跳过
任意 + arity > 4 的函数永远跳过(隐蔽的"坑")
错误中间件调用 next()错误消除,进入普通中间件
错误中间件调用 next(err)错误继续传递到下一个错误中间件

错误中间件顺序的影响

// 此错误中间件在产生错误的中间件之前,不会被触发
app.use(function(err, req, res, next) { res.end('fail'); }); // ← 先注册
app.use(function(req, res, next) { next(new Error('boom!')); });
app.use(function(err, req, res, next) { res.end('pass'); }); // ← 会被触发

错误只会传递给产生错误的中间件之后注册的错误中间件。

九、defer 的作用

var defer = typeof setImmediate === 'function'
  ? setImmediate
  : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) }

用于 stack 走完时异步调用兜底处理器:

if (!layer) {
  defer(done, err);  // 异步,不是同步调用
  return;
}

为什么必须异步?

若直接同步调用 done(err),在复杂嵌套场景(如多层子 app)中可能导致当前调用栈未完全清空就触发兜底逻辑,引发难以追踪的状态问题。

setImmediatedone 推迟到下一个事件循环迭代,确保当前调用栈完全清空。

十、子 app 嵌套挂载(详解)

挂载时的包装

if (typeof handle.handle === 'function') {
  var server = handle;
  server.route = path;            // 更新子 app 的 route 属性
  handle = function (req, res, next) {
    server.handle(req, res, next); // 传入外层(父)next ← 关键
  };
}

嵌套调用链

父 app.stack = [mw1, subApp('/blog'), mw3]

请求 GET /blog/post/11. mw1 执行 → 调用 next()
  2. 进入 subApp 包装函数
       → subApp.handle(req, res, 父next)
       → req.url 被裁为 '/post/1'
         subApp.stack = [subMw1, subMw2]
           subMw1 执行 → 调用 next()
           subMw2 执行 → 调用 next()
           subApp stack 走完 → defer(父next, err)
       → req.url 还原为 '/blog/post/1'
  3. mw3 执行(回到父 app)

route 属性的作用

assert.equal(app.route,   '/');      // 根 app
assert.equal(blog.route,  '/blog');  // 挂载在 /blog
assert.equal(admin.route, '/admin'); // 挂载在 /admin(相对于 blog)

route 记录的是挂载点的绝对路径,主要用于调试和日志。

十一、设计哲学总结

connect 的全部复杂性集中在约 100 行的 handle / next / call 三函数中,体现了以下经典设计决策:

设计目标实现方式
通过参数个数区分中间件类型call() 检查 Function.lengtharity
路径透明(子中间件只看相对路径)进入前裁剪 URL,next() 时还原
不丢失原始请求路径req.originalUrl 首次赋值后不变
同步异常自动转为错误传播call()try/catch 捕获后调用 next(err)
子 app 可以串联回父 appuse() 包装时捕获并传入外层 next
兜底处理不影响当前调用栈defersetImmediate)异步调用 done

十二、快速验证

# 运行所有测试
npm test

# 运行单个测试文件
npx mocha --require test/support/env test/mounting.js
npx mocha --require test/support/env test/fqdn.js

测试文件说明

文件覆盖内容
test/server.js基本功能、404/500 处理、EventEmitter
test/mounting.js路径匹配、URL 裁剪、子 app 挂载、错误处理
test/fqdn.js绝对 URL(FQDN)场景
test/app.listen.jsproto.listen() 方法