connect 源码解析

技术 4月 29, 2021

概述

connect 是 NodeJS 的一个可扩展的 HTTP 服务框架,它可以使用中间件来扩展功能

vitewebpack-dev-middleware 均是使用 connect 来构建开发服务器,同时 Express 的中间件模式也是借鉴于 connect。

笔者在阅读 vite 源码的时候,发现 vite 是使用 connect 为核心来构建开发服务器,便有了这篇文章。文章分两部分,大头部分是对 connect 的源码讲解,后面一些部分会对中间件的设计以及 vite 如何使用 connect 做些简单的讲解。

如有疏漏不妥之处,还请不吝斧正!

基本使用

const connect = require('connect');
const http = require('http');

const app = connect();

// 基础中间件用法
app.use(function middleware1(req, res, next) {
  next();
});

// URL 匹配中间件
app.use('/foo', function fooMiddleware(req, res, next) {
  // req.url starts with "/foo"
  next();
});

// next() 中传入了内容则视为发生错误,然后会传递给处理错误的中间件
app.use(function (req, res, next) {
  next(new Error('boom!'));
});

// 错误处理中间件
// 有四个入参的视为处理错误的中间件
app.use(function onerror(err, req, res, next) {
  // an error occurred!
});

// 服务启动
app.listen(2000)
// 或者将中间件传递给 http 来创建服务
http.createServer(app).listen(3000);

源码阅读

初始化

createServer

创建一个 connect 实例, 返回一个 app 函数。

const app = connect() 中的 connect 便是 createServer

function createServer() {
  // 声明实例 app
  // 调用 app 就是调用 app 上的静态函数 handle
  function app(req, res, next){ app.handle(req, res, next); }
  // 将 proto 上的静态属性挂载到 app 上
  merge(app, proto);
  // 将 EventEmitter 上的静态属性挂载到 app 上
  merge(app, EventEmitter.prototype);
  // 设置默认路由为 '/'
  app.route = '/';
  // 初始化默认中间件的堆栈
  app.stack = [];
  return app;
}

实例函数

存储着实例上的静态属性/方法

use

添加中间件至 app.stack
image.png

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

  // 函数重载
  // 如果传进来的第一个参数不是字符串,则直接视作只有处理函数的中间件,并将该中间件的处理 url 设为 '/'
  if (typeof route !== 'string') {
    handle = route;
    path = '/';
  }

  // 函数重载
  // 如果传入的中间件函数有静态函数 handle,则直接视作传入的是另一个 connect 实例
  if (typeof handle.handle === 'function') {
    var server = handle;
    server.route = path;
    handle = function (req, res, next) {
      server.handle(req, res, next);
    };
  }

  // 函数重载
  // 直接将 createServer 中的第一个参数,也就是处理请求的函数当做处理函数
  if (handle instanceof http.Server) {
    handle = handle.listeners('request')[0];
  }

  // path 格式化
  // 如果以 path 以 '/' 结尾则删除该 '/'
  if (path[path.length - 1] === '/') {
    path = path.slice(0, -1);
  }

  // 输出 debug 信息
  debug('use %s %s', path || '/', handle.name || 'anonymous');
  // 入栈
  this.stack.push({ route: path, handle: handle });

  return this;
};

handle

处理请求的函数,闭包存储了一些变量,核心是递归调用 next 函数,由 next 调用中间件处理请求

Connect.png

function handle(req, res, out) {
  // 中间件下标
  var index = 0;
  // 请求的 url 的协议加主机内容,eg:https://www.example.com
  var protohost = getProtohost(req.url) || '';
  // 删除掉的路由前缀,比如有些中间件需要路由要匹配,则会删除对应前缀,并暂存在这,而后缀则会交给对应的中间件处理(可以先跳过,后面会有讲解)
  var removed = '';
  // 是否对 url 补充了前置的 '/'(可以先跳过,后面会有讲解)
  var slashAdded = false;
  // 中间件集合
  var stack = this.stack;

  // 中间件全部结束后调用的收尾函数
  // 如果上层应用有传下来的收尾函数,则使用其函数(比如说上一层 connect 传下来的 next)
  // 反之使用 finalhandler 生成的收尾函数
  var done = out || finalhandler(req, res, {
    env: env,
    onerror: logerror
  });

  // 存储其原始 URL
  req.originalUrl = req.originalUrl || req.url;

  // 定义 next 函数
  function next(err) {
    // 如果在上一个中间件,对 URL 添加了前缀 '/',则还原并重置
    if (slashAdded) {
      req.url = req.url.substr(1);
      slashAdded = false;
    }

    // 如果再上一个中间件,对 URL 进行了前缀匹配,则还原并重置
    if (removed.length !== 0) {
      req.url = protohost + removed + req.url.substr(protohost.length);
      removed = '';
    }

    // 获取当前中间件
    var layer = stack[index++];

    // 如果没有中间件了,异步调用收尾函数,结束。
    if (!layer) {
      defer(done, err);
      return;
    }

    // 通过 parseUrl 获取当前请求 URL 的 pathname
    var path = parseUrl(req).pathname || '/';
    // 中间件对应的路由
    var route = layer.route;

    // 如果当前请求的 URL 和 中间件的路由不匹配则直接跳过,结束
    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
      return next(err);
    }

    // 如果路径前缀匹配了,但是后缀表示着其实是个误会则依旧视为不匹配直接跳过,结束
    // eg:/foo 与 /fooo 是不匹配的
    var c = path.length > route.length && path[route.length];
    if (c && c !== '/' && c !== '.') {
      return next(err);
    }

    // 将匹配后的路由交给中间件,中间件的路由是 /foo,请求的 URL 是 /foo/bar,则将 /bar 给中间件
    if (route.length !== 0 && route !== '/') {
      // 即将删除的路由前缀
      removed = route;
      // 删除了中间件对应路由前缀后的 URL,下一个中间件会还原
      req.url = protohost + req.url.substr(protohost.length + removed.length);

      // 如果不存在具体协议域名前缀,同时 url 开头不为 '/',则自动补充并标记,下一个中间件处理时会还原
      if (!protohost && req.url[0] !== '/') {
        req.url = '/' + req.url;
        slashAdded = true;
      }
    }

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

  // 启动 next
  next();
};

listen

利用 http.createServer 启动服务

proto.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

工具函数

defer

异步调用函数

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

call

调用中间件的函数

// handle: 当前中间件的处理函数
// route: 当前所属路由,默认为 '/',反之应该是当前中间件所匹配的路由
// err:可能存在的错误信息
// req:request
// res:response
// next:执行下一个中间件
function call(handle, route, err, req, res, next) {
  // 中间件的入参数量
  var arity = handle.length;
  var error = err;
  // 是否有错误抛出
  var hasError = Boolean(err);

  // 初始 debug 信息
  debug('%s %s : %s', handle.name || '<anonymous>', route, req.originalUrl);

  try {
    if (hasError && arity === 4) {
    	// 如果有错误抛出,并且当前中间件是处理错误的中间件,则调用,结束。
      handle(err, req, res, next);
      return;
    } else if (!hasError && arity < 4) {
      // 如果没有错误,并且当前中间件为非处理错误用的中间件,则调用,结束。
      handle(req, res, next);
      return;
    }
  } catch (e) {
    // 捕获错误并覆盖上一个可能存在的错误
    error = e;
  }

  // 出现错误 || (有错误 && 当前中间件不是处理错误的中间件函数),执行下一个中间件
  next(error);
}

logerror

输出错误的函数

function logerror(err) {
  // env = process.env.NODE_ENV || 'development'
  if (env !== 'test') console.error(err.stack || err.toString());
}

getProtohost

获取 url 的协议加域名
eg: http://www.example.com/foo => http://www.example.com

function getProtohost(url) {
  if (url.length === 0 || url[0] === '/') {
    return undefined;
  }

  var fqdnIndex = url.indexOf('://')

  return fqdnIndex !== -1 && url.lastIndexOf('?', fqdnIndex) === -1
    ? url.substr(0, url.indexOf('/', 3 + fqdnIndex))
    : undefined;
}

中间件处理机制分析

示例

const connect = require('connect')
const app = connect()

app.use(function m1(req, res, next) {
  console.log('m1 start ->');
  next();
  console.log('m1 end <-');
});

app.use(function m2(req, res, next) {
  console.log('m2 start ->');
  next();
  console.log('m2 end <-');
});

app.use(function m3(req, res, next) {
  console.log('m3 service...');
});

app.listen(4000)

运行流程

按照先前的代码分析,3 个中间件的运行流程大概可以这样描述:
middleware (2).png
是不是有点眼熟了?

洋葱模型?

connect 的中间件是洋葱模型吗?可以说是也可以说不是。

在全部都是 同步 逻辑的时候,connect 可以实现洋葱模型的效果

但如果有个环节是 异步 的,并且你想让后面的逻辑暂时阻塞,等待异步结束后运行,那 connect 只能是线性的。

核心的原因是,next() 是同步调用中间件的,即时中间件内部进行了异步控制也无济于事,其实也是 Express 之前与 Koa 中间件模式的区别,由于异步事件的无法忽略性,前者中间件往往只能是线性传递的,而后者的中间件却可以实现洋葱模型的方式进行逻辑处理。

同时也不得不承认的是 Koa 的中间件模式确实比 Express 的中间件模式更优秀

Koa 中间件的源码分析,可以看笔者的这篇文章 《Koa 的中间件完整流程 + 源码分析》

vite 与 connect

vite 在 1.x 和 2.x 早期的时候其实是使用 Koa 去实现中间件模式的,为什么从 Koa 迁移到 connect 呢,从 《Migration from v1》的最后一段话可以了解到其原因。

Since most of the logic should be done via plugin hooks instead of middlewares, the need for middlewares is greatly reduced. The internal server app is now a good old connect instance instead of Koa.

由于 vite 的逻辑处理由原先的中间件处理逐渐倾向于使用插件的钩子函数来处理,所以 vite 对中间件模式的依赖逐渐变小,而采用了更合适的 connect。

vite 里是如何使用 Connect 的

笔者将 vite 创建开发服务器的代码 做了简化,会忽略一下无关的细节(比如 middlewareMode 模式的判断),只要意会就行

export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
	// ...
  
  // 通过 connect 创建一个 node 中间件
  const middlewares = connect() as Connect.Server
  
  // 会根据 config.server 的配置创建 http/https 服务器,并将 request 交由 middlewares 来处理,类似之前基础使用介绍中最后一个用法
  const httpServer = await resolveHttpServer(serverConfig, middlewares)
  
  // ...
  
  // debug 用的时间戳日志中间件
  if (process.env.DEBUG) {
    middlewares.use(timeMiddleware(root))
  }
  
  // cors 中间件 
  // 对应配置:https://vitejs.bootcss.com/config/#server-cors
  // 对应库:https://www.npmjs.com/package/cors
  const { cors } = serverConfig
  if (cors !== false) {
    middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors))
  }
  
  // proxy
  // 处理代理配置的中间件 
  // 对应配置:https://vitejs.bootcss.com/config/#server-proxy
  const { proxy } = serverConfig
  if (proxy) {
    middlewares.use(proxyMiddleware(httpServer, config))
  }
  
  // 处理 base url 的中间件
  // 对应配置:https://vitejs.bootcss.com/config/#base
  if (config.base !== '/') {
    middlewares.use(baseMiddleware(server))
  }

  // 打开编辑器的中间件
  // 对应库:https://github.com/yyx990803/launch-editor#readme
  middlewares.use('/__open-in-editor', launchEditorMiddleware())

  // 重连中间件
  middlewares.use('/__vite_ping', (_, res) => res.end('pong'))

  // 转义 url 中间件
  middlewares.use(decodeURIMiddleware())
  
  // 处理 public 下文件的中间件
  middlewares.use(servePublicMiddleware(config.publicDir))
  
  // main transform middleware
  // ! 核心内容转换中间件
  middlewares.use(transformMiddleware(server))
  
  // 文件处理中间件
  middlewares.use(serveRawFsMiddleware())
  middlewares.use(serveStaticMiddleware(root, config))
  
	// 处理 index.html 的中间件
  middlewares.use(indexHtmlMiddleware(server))
  
  // 上面都不好使时 404
  middlewares.use((_, res) => {
    res.statusCode = 404
    res.end()
  })
  
  // error handler
  // 错误处理中间件
  middlewares.use(errorMiddleware(server, middlewareMode))
  
  // ...
}

而我们在使用 vite 开发时,就是由上面的各种中间件加工我们的模块然后返回给浏览器来处理的。

参考

Pengsha Ying

逝者如斯,故不舍昼夜