Back
Featured image of post Node HTTP Server 源码解读

Node HTTP Server 源码解读

很多同学或多或少都使用过 Node 创建 HTTP Server 处理 HTTP 请求,可能是简易的博客,或者已经是负载千万级请求的大型服务。但是我们可能并没有深入了解过 Node 创建 HTTP Server 的过程,希望借这篇文章,让大家对 Node 更加了解。

先上流程图,帮助大家更容易的理解源码

初探

我们先看一个简单的创建 HTTP Server 的例子,基本的过程可以分为两步

  • 使用 createServer 获取 server 对象
  • 调用 server.listen 开启监听服务
const http = require('http')

// 创建 server 对象
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('响应内容');
});

// 开始监听 3000 端口的请求
server.listen(3000)

这个过程是非常简单,下面我们会根据这个过程,结合源码,开始分析 Node 创建 HTTP Server 的具体内部过程。

在此之前,为了更好的理解代码,我们需要了解一些基本的概念:

fd - 文件描述符

文件描述符(File descriptor)是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的,该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

handle - 句柄

句柄(handle)是 Windows 操作系统用来标识被应用程序所建立或使用的对象的整数。其本质相当于带有引用计数的智能指针。当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,可以使用句柄。Unix 系统的文件描述符基本上也属于句柄。

本文中的 handle 可以理解为相关对象的引用。

文中用 ... 符合表示略去了部分和本文讨论内容关联性较低的,不影响主要逻辑的代码,如参数处理、属性赋值等。

http.createServer

createServer 是个工厂方法,返回了 _http_server 模块中的 Server 类的实例,而 Server 是从 _http_server 文件导出的

const {
  Server,
} = require('_http_server');

// http.createServer
function createServer(opts, requestListener) {
  return new Server(opts, requestListener);
}

_http_server

_http_server 模块的 Server 类中可以看出,http.Server 是继承于 net.Server

function Server(options, requestListener) {
  // 可以不使用 new 直接调用 http.Server()
  if (!(this instanceof Server)) return new Server(options, requestListener);
  
  // 参数适配
  // ...

	// 继承
  net.Server.call(this, { allowHalfOpen: true });

  if (requestListener) {
    this.on('request', requestListener);
  }

	// ...
  this.on('connection', connectionListener);
	// ...
}

// http.Server 继承自 net.Server
ObjectSetPrototypeOf(Server.prototype, net.Server.prototype);
ObjectSetPrototypeOf(Server, net.Server);

...

这里的继承关系也比较好理解:Node 中的 net.Server 是用于创建 TCP 或 IPC 服务器的模块。我们都知道,HTTP 是应用层协议,而 TCP 是传输层协议。HTTP 通过 TCP 传输数据,并进行再次的解析。Node 中的 HTTP 模块基于 TCP 模块做了再封装,实现了不同的解析处理逻辑,即出现了我们看到的继承关系。

类似的,net.Server 继承了 EventEmitter 类,拥有许多事件触发器,包含一些属性信息,感兴趣的同学可以自行查阅。

至此,我们可以看到,createServer 只是 net.Server 的实例化过程,并没有创建服务监听,而是由 server.listen 方法实现。

server.listen

当创建完成 server 实例后,通常需要调用 server.listen 方法启动服务,开始处理请求,如 Koa 的 app.listenlisten 方法支持多种使用方式,下面我们一一分析

1. server.listen(handle[, backlog][, callback])

第一种是不太常见的用法,Node 允许我们启动一个服务器,监听已经绑定到端口、Unix 域套接字或 Windows 命名管道的,给定的 handle 上的连接。

handle 对象可以是服务器、套接字(任何具有底层 _handle 成员的东西),也可以是具有 fd(文件描述符) 属性的对象,如我们通过 createServer 创建的 Server 对象。

当识别到是 handle 对象之后,就会调用 listenInCluster 方法,从方法的名字,我们可以猜测到这个就是启动服务监听的方法:

// handle 是具有 _handle 属性的对象
if (options instanceof TCP) {
  this._handle = options;
  this[async_id_symbol] = this._handle.getAsyncId();
  listenInCluster(this, null, -1, -1, backlogFromArgs);
  return this;
}

// 当 handle 是具有 fd 属性的对象
if (typeof options.fd === "number" && options.fd >= 0) {
  listenInCluster(this, null, null, null, backlogFromArgs, options.fd);
  return this;
}

2. server.listen([port[, host[, backlog]]][, callback])

第二种是我们常见的监听端口,Node 允许我们创建一个服务器,监听给定的 host 上的端口,host 可以是 IP 地址,或者域名链接,当 host 是域名链接时,Node 会先使用 dns.lookup 获取 IP 地址。最后,检验完端口合法后,同样是调用了 listenInCluster方法,源码🔗

3. [server.listen(path[, backlog][, callback])](http://nodejs.cn/s/yW8Zc1)

第三种,Node 允许启动一个 IPC 服务器监听指定的 IPC 路径,即 Windows 上的命名管道 IPC以及 其他类 Unix 系统中的 Unix Domain Socket。

这里的 path 参数是识别 IPC 连接的路径。 在 Unix 系统上,参数 path 表现为文件系统路径名,在 Windows 上,path 必须是以 \\?\pipe\\\.\pipe\ 为入口。

然后,同样是调用了 listenInCluster 方法,源码🔗

还有一种调用方法 server.listen(options[, callback]) 是端口和 IPC 路径的另外一种调用方式,这里就不多说了。

最后就是对不符合上述所有条件的异常情况,抛出错误。

小结

至此,我们可以看到,server.listen 方法对不同的调用方式做了解析,并调用了 listenInCluster 方法。

listenInCluster

首先,我们要对 clsuter 做一个简单的介绍。

我们都知道 JavaScript 是单线程运行的,一个线程只会在一个 CPU 核心上运行。而现代的处理都是多核心的,为了充分利用多核,就需要启用多个 Node.js 进程去处理负载任务。

Node 提供的 cluster 模块解决了这个问题 ,我们可以使用 cluster 创建多个进程,并且同时监听同一个端口,而不会发生冲突,是不是很神奇?不要着急,下面我们就会解密这个神奇的 cluster 模块。

先看一个 cluster 的简单用法:

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

if (cluster.isMaster) {
  // 衍生工作进程。
  for (let i = 0; i < 4; i++) {
    cluster.fork();
  }
} else {
  // 工作进程可以共享任何 TCP 连接。
  // 在本例子中,共享的是 HTTP 服务器。
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('你好世界\n');
  }).listen(8000);
}

基于 cluster 的用法,负责启动其他进程的叫做 master 进程,不做具体的工作,只负责启动其他进程。其他被启动的进程则叫 worker 进程,它们接收请求,并对外提供服务。

listenInCluster 方法主要做了一件事:区分 master 进程(cluster.isMaster)和 worker 进程,采用不同的处理策略:

  • master 进程:直接调用 server._listen 启动监听
  • worker进程:使用 clsuter._getServer 处理传入的 server 对象,修改 server._handle 再调用了 server._listen 启动监听
function listenInCluster(...) {
  // 引入 cluster 模块
  if (cluster === undefined) cluster = require('cluster');

  // master 进程
  if (cluster.isMaster || exclusive) {
    server._listen2(address, port, addressType, backlog, fd, flags);
    return;
  }

  // 非 master 进程,即通过 cluster 启动的子进程
  const serverQuery = {
    address: address,
    port: port,
    addressType: addressType,
    fd: fd,
    flags,
  };
  
  // 调用 cluster 的方法处理
  cluster._getServer(server, serverQuery, listenOnMasterHandle);

  function listenOnMasterHandle(err, handle) {
    // ...
    server._handle = handle;
    server._listen2(address, port, addressType, backlog, fd, flags);
  }
}

master 进程

我们先看 master 进程的处理方法 server._listen2server._listen2setupListenHandle 的别名。

setupListenHandle 主要是负责根据 server 监听连接的不同类型,调用 createServerHandle 方法获取 handle 对象,并调用 handle.listen 方法开启监听。

function setupListenHandle(address, port, addressType, backlog, fd, flags) {
  // 如果是 handle 对象,需要创一个 handle 对象
  if (this._handle) {
    // do nothing
  } else {
    let rval = null;
    // 在 host 和 port 省略,且没有指定 fd 的情况下
    // 如果 IPv6 可用,服务器将会接收基于未指定的 IPv6 地址 (::) 的连接
    // 否则接收基于未指定的 IPv4 地址 (0.0.0.0) 的连接。
    if (!address && typeof fd !== 'number') {
      rval = createServerHandle(DEFAULT_IPV6_ADDR, port, 6, fd, flags);

      if (typeof rval === 'number') {
        rval = null;
        address = DEFAULT_IPV4_ADDR;
        addressType = 4;
      } else {
        address = DEFAULT_IPV6_ADDR;
        addressType = 6;
      }
    }
    
    // fd 或 IPC
    if (rval === null)
      rval = createServerHandle(address, port, addressType, fd, flags);

    // 如果 createServerHandle 返回的是数字,则表明出现了错误,进程退出
    if (typeof rval === 'number') {
      const error = uvExceptionWithHostPort(rval, 'listen', address, port);
      process.nextTick(emitErrorNT, this, error);
      return;
    }

    this._handle = rval;
  }
   
  ...

  // 开始监听
  const err = this._handle.listen(backlog || 511);

  ...
  // 触发 listening 方法
}

createServerHandle 负责调用 C++tcp_warp.ccpipe_wrap 模块创建 PIPETCP 服务。PIPETCP 对象都拥有 listen 方法,listen 方法是对 uvlib 中的 [uv_listen](http://docs.libuv.org/en/v1.x/stream.html?highlight=uv_listen#c.uv_listen) 方法的封装,与 Linux 中的 [listen(2)](https://man7.org/linux/man-pages/man2/listen.2.html) 类似。可以调用系统能力,开始监听传入的连接,并在收到新连接后回调请求信息。

PIPE 是对 Unix 上的流文件(包括 socket,pipes)以及 Windows 上的命名管道的抽象封装,TCP 就是对 TCP 服务的封装。

function createServerHandle(address, port, addressType, fd, flags) {
  // ...
  let isTCP = false;
  // 当 fd 选项存在时
  if (typeof fd === 'number' && fd >= 0) {
    try {
      handle = createHandle(fd, true);
    } catch (e) {
      debug('listen invalid fd=%d:', fd, e.message);
      // uvlib 中的错误码,表示非法的参数,是个负数
      return UV_EINVAL;
    }
    ...
  } else if (port === -1 && addressType === -1) {
    // 当 port 和 address 不存在时,即监听 Socket 或 IPC 等
    // 创建 Pipe Server
    handle = new Pipe(PipeConstants.SERVER);
    ...
  } else {
    // 创建 TCB SERVER
    handle = new TCP(TCPConstants.SERVER);
    isTCP = true;
  }
  // ...
  return handle;
}

小结

master 进程的 server.listen 处理逻辑较为简单,可以概括为直接调用 libuv ,使用系统能力,开启监听服务。

worker 进程

如果当前进程不是 master 进程,事情就会变得复杂许多。

listenInCluster 方法会调用 cluster 模块导出的 _getServer 方法,cluster 模块会通过当前进程是否包含 NODE_UNIQUE_ID 判断当前进程是否子进程,分别使用 childmaster 文件的导出变量,相应的处理方法也会有所不同

const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';

module.exports = require(`internal/cluster/${childOrMaster}`);

我们所说的 worker 进程,没有 NODE_UNIQUE_ID 环境变量,会使用 child 模块导出的 _getServer 方法。

worker 进程的 _getServer 方法主要做了以下两件事情:

  • 通过发送 internalMessage,即进程间通信的方式,向 master 进程传递消息,调用 queryServe,注册当前 worker 进程的信息。若 master 进程是第一次接收到监听此端口/fdworker,则起一个内部 TCP 服务器,来承担监听该端口/fd 的职责,随后在 master 中记录下该 worker
  • 如果是轮训监听(RoundRobinHandle),就修改掉 worker 进程中的 net.Server 实例的 listen 方法里监听端口/fd的部分,使其不再承担监听职责。
// obj 是 net.Server 或 Socket 的实例
cluster._getServer = function(obj, options, cb) {
  let address = options.address;
  // ...
  // const indexesKey = ...;
  // indexes 为 Map 对象
  indexes.set(indexesKey, index);

  const message = {
    act: 'queryServer',
    index,
    data: null,
    ...options
  };

  message.address = address;

  // 发送 internalMessage 通知 Master 进程
  // 接受 Master 进程的回调
  send(message, (reply, handle) => {
    if (typeof obj._setServerData === 'function')
      obj._setServerData(reply.data);

    if (handle)
      // 关闭连接时,移除 handle 避免内存泄漏
      shared(reply, handle, indexesKey, cb);  // Shared listen socket.
    else
      // 伪造了 listen 等方法
      rr(reply, indexesKey, cb);              // Round-robin.
  });

  // ...
};

master 中的 queryServer 接收到到消息后,会根据不同的条件(平台、协议等)分别创建 RoundRobinHandleSharedHandle ,即 cluster 两种分发处理连接的方法。

同时 master 进程会将监听端口、地址等信息组成的 key 作为唯一标志,记录 handle 和对应 worker 的信息。

function queryServer(worker, message) {
  // ...
  const key = `${message.address}:${message.port}:${message.addressType}:` +
              `${message.fd}:${message.index}`;
  let handle = handles.get(key);

  if (handle === undefined) {
    let address = message.address;
    ...
    let constructor = RoundRobinHandle;
    if (schedulingPolicy !== SCHED_RR ||
        message.addressType === 'udp4' ||
        message.addressType === 'udp6') {
      constructor = SharedHandle;
    }

    handle = new constructor(key, address, message);
    handles.set(key, handle);
  }

  // ...
  handle.add(worker, (errno, reply, handle) => {
    const { data } = handles.get(key);
    // ...
    send(worker, {
      errno,
      key,
      ack: message.seq,
      data,
      ...reply
    }, handle);
  });
}

RoundRobinHandle

RoundRobinHandle(也是除 Windows 外所有平台的默认方法)的处理模式为:由 master 进程负责监听端口,接收新连接后再将连接循环分发给 worker 进程,即将请求放到一个队列中,从空闲的 worker 池中分出一个处理请求,处理完成后在放回 worker 池中,以此类推

function RoundRobinHandle(key, address, { port, fd, flags }) {
  this.key = key;
  this.all = new Map();
  this.free = new Map();
  this.handles = [];
  this.handle = null;
  // 创建 Server
  this.server = net.createServer(assert.fail);

  // 开启监听,多种情况,省略
  // this.server.listen(...)

  this.server.once('listening', () => {
    this.handle = this.server._handle;
    // 收到请求,分发处理
    this.handle.onconnection = (err, handle) => this.distribute(err, handle);
    this.server._handle = null;
    this.server = null;
  });
}

// ...

RoundRobinHandle.prototype.distribute = function(err, handle) {
  this.handles.push(handle);
  const [ workerEntry ] = this.free;

  if (ArrayIsArray(workerEntry)) {
    const [ workerId, worker ] = workerEntry;
    this.free.delete(workerId);
    this.handoff(worker);
  }
};

RoundRobinHandle.prototype.handoff = function(worker) {
  if (!this.all.has(worker.id)) {
    return;  // Worker is closing (or has closed) the server.
  }

  const handle = this.handles.shift();

  if (handle === undefined) {
    this.free.set(worker.id, worker);  // Add to ready queue again.
    return;
  }

  const message = { act: 'newconn', key: this.key };

  sendHelper(worker.process, message, handle, (reply) => {
    if (reply.accepted)
      handle.close();
    else
      this.distribute(0, handle);

    this.handoff(worker);
  });
};

SharedHandle

SharedHandle 的处理模式为:master 进程创建监听服务器 ,再将服务器的 handle 发送 worker 进程,由 worker 进程负责直接接收连接

function SharedHandle(key, address, { port, addressType, fd, flags }) {
  this.key = key;
  this.workers = new Map();
  this.handle = null;
  this.errno = 0;

  let rval;
  if (addressType === 'udp4' || addressType === 'udp6')
    rval = dgram._createSocketHandle(address, port, addressType, fd, flags);
  else
    rval = net._createServerHandle(address, port, addressType, fd, flags);

  if (typeof rval === 'number')
    this.errno = rval;
  else
    this.handle = rval;
}

// 添加存储 worker 信息
SharedHandle.prototype.add = function(worker, send) {
  assert(!this.workers.has(worker.id));
  this.workers.set(worker.id, worker);
  // 向 worker 进程发送 handle
  send(this.errno, null, this.handle);
};
// ..

PS:Windows 之所以不采用 RoundRobinHandle 的原因是因为性能原因。从理论上来说,第二种方法应该是效率最佳的。 但在实际情况下,由于操作系统调度机制的难以捉摸,会使分发变得不稳定,可能会出现八个进程中有两个分担了 70% 的负载。相比而言,轮训的方法会更加高效。

小结

worker 进程中,每个 worker 不再独立开启监听服务,而是由 master 进程开启一个统一的监听服务,接受请求连接,再将请求转发给 worker 进程处理。

总结

在不同的情况下,Node 创建 HTTP Server 的流程是不一致的。当进程为 master 进程时,Node 会直接通过 libuv 调用系统能力开启监听。当进程为 child 进程(worker 进程)时,Node 会使用 master 进程开启间监听,并通过轮训或共享 Handle 的方式将连接分发给 child 进程处理。

最后,写文章不容易,如果大家喜欢的话,欢迎一键三联~

Licensed under CC BY-NC-SA 4.0