前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >通过nodejs源码理解http connect的原理和实现

通过nodejs源码理解http connect的原理和实现

作者头像
theanarkh
发布2020-11-26 10:26:37
2.1K0
发布2020-11-26 10:26:37
举报
文章被收录于专栏:原创分享原创分享

分析http connect实现之前我们首先看一下为什么需要http connect方法或者说他出现的背景。connect方法主要用于代理服务器的请求转发。我们看一下传统http服务器的工作原理。

1 客户端和代理服务器建立tcp连接

2 客户端发送http请求给代理服务器

3 代理服务器解析http协议,根据配置拿到业务服务器的地址

4 代理服务器和业务服务器建立tcp连接,通过http协议或者其他协议转发请求

5 业务服务器返回数据,代理服务器回复http报文给客户端。

接着我们看一下https服务器的原理。

1 客户端和服务器建立tcp连接

2 服务器通过tls报文返回证书信息,并和客户端完成后续的tls通信。

3 完成tls通信后,后续发送的http报文会经过tls层加密解密后再传输。

那么如果我们想实现一个https的代理服务器怎么做呢?因为客户端只管和直接相连的服务器进行https的通信,如果我们的业务服务器前面还有代理服务器,那么代理服务器就必须要有证书才能和客户端完成tls握手,从而进行https通信。代理服务器和业务服务器使用http或者https还是其他协议都可以。这样就意味着我们所有的服务的证书都需要放到代理服务器上,这种场景的限制是,代理服务器和业务服务器都由我们自己管理或者公司统一管理。如果我们想加一个代理对业务服务器不感知那怎么办呢(比如写一个代理服务器用于开发调试)?有一种方式就是为我们的代理服务器申请一个证书,这样客户端和代理服务器就可以完成正常的https通信了。从而也就可以完成代理的功能。另外一种方式就是http connect方法。http connect方法的作用是指示服务器帮忙建立一条tcp连接到真正的业务服务器,并且透传后续的数据,这样不申请证书也可以完成代理的功能。

这时候代理服务器只负责透传两端的数据,不像传统的方式一样解析请求然后再转发。这样客户端和业务服务器就可以自己完成tls握手和https通信。代理服务器就像不存在一样。了解了connect的原理后看一下来自nodejs官方的一个例子。

代码语言:javascript
复制
const http = require('http');
const net = require('net');
const { URL } = require('url');
// 创建一个http服务器作为代理服务器
const proxy = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('okay');
});
// 监听connect事件,有http connect请求时触发
proxy.on('connect', (req, clientSocket, head) => {
  // 获取真正要连接的服务器地址并发起连接
  const { port, hostname } = new URL(`http://${req.url}`);
  const serverSocket = net.connect(port || 80, hostname, () => {
    // 连接成功告诉客户端
    clientSocket.write('HTTP/1.1 200 Connection Established\r\n' +
                    'Proxy-agent: Node.js-Proxy\r\n' +
                    '\r\n');
    // 透传客户端和服务器的数据  
    serverSocket.write(head);            
    serverSocket.pipe(clientSocket);
    clientSocket.pipe(serverSocket);
  });
});

proxy.listen(1337, '127.0.0.1', () => {

  const options = {
    port: 1337,
    // 连接的代理服务器地址
    host: '127.0.0.1',
    method: 'CONNECT',
    // 我们需要真正想访问的服务器地址
    path: 'www.baidu.com',
  };
  // 发起http connect请求
  const req = http.request(options);
  req.end();
  // connect请求成功后触发
  req.on('connect', (res, socket, head) => {
    // 发送真正的请求
    socket.write('GET / HTTP/1.1\r\n' +
                 'Host: www.baidu.com\r\n' +
                 'Connection: close\r\n' +
                 '\r\n');
    socket.on('data', (chunk) => {
      console.log(chunk.toString());
    });
    socket.on('end', () => {
      proxy.close();
    });
  });
});

官网的这个例子很好地说明了connect的原理,如下图所示。

下面我们看一下nodejs中connect的实现。我们从http connect请求开始。之前的文章已经分析过,客户端和nodejs服务器建立tcp连接后,nodejs收到数据的时候会交给http解析器处理,

代码语言:javascript
复制
// 连接上有数据到来
function socketOnData(server, socket, parser, state, d) {
  // 交给http解析器处理,返回已经解析的字节数
  const ret = parser.execute(d);
  onParserExecuteCommon(server, socket, parser, state, ret, d);
}

http解析数据的过程中会不断回调nodejs的回调,然后执行onParserExecuteCommon。我们这里只关注当nodejs解析完所有http请求头后执行parserOnHeadersComplete。

代码语言:javascript
复制
function parserOnHeadersComplete(versionMajor, versionMinor, headers, method,
                                 url, statusCode, statusMessage, upgrade,
                                 shouldKeepAlive) {
  const parser = this;
  const { socket } = parser;

  // IncomingMessage
  const ParserIncomingMessage = (socket && socket.server &&
                                 socket.server[kIncomingMessage]) ||
                                 IncomingMessage;
  // 新建一个IncomingMessage对象
  const incoming = parser.incoming = new ParserIncomingMessage(socket);
  incoming.httpVersionMajor = versionMajor;
  incoming.httpVersionMinor = versionMinor;
  incoming.httpVersion = `${versionMajor}.${versionMinor}`;
  incoming.url = url;
  // 是否是connect请求或者upgrade请求
  incoming.upgrade = upgrade;

  // 执行回调
  return parser.onIncoming(incoming, shouldKeepAlive);
}

我们看到解析完http头后,nodejs会创建一个表示请求的对象IncomingMessage,然后回调onIncoming。

代码语言:javascript
复制
function parserOnIncoming(server, socket, state, req, keepAlive) {
  // 请求是否是connect或者upgrade
  if (req.upgrade) {
    req.upgrade = req.method === 'CONNECT' ||
                  server.listenerCount('upgrade') > 0;
    if (req.upgrade)
      return 2;
  }
 // ...
}

nodejs解析完头部并且执行了响应的钩子函数后,会执行onParserExecuteCommon。

代码语言:javascript
复制
function onParserExecuteCommon(server, socket, parser, state, ret, d) {
  if (ret instanceof Error) {
    prepareError(ret, parser, d);
    ret.rawPacket = d || parser.getCurrentBuffer();
    debug('parse error', ret);
    socketOnError.call(socket, ret);
  } else if (parser.incoming && parser.incoming.upgrade) {
    // 处理Upgrade或者CONNECT请求
    const req = parser.incoming;
    const eventName = req.method === 'CONNECT' ? 'connect' : 'upgrade';
    // 监听了对应的事件则处理,否则关闭连接
    if (eventName === 'upgrade' || server.listenerCount(eventName) > 0) {
      // 还没有解析的数据
      const bodyHead = d.slice(ret, d.length);
      socket.readableFlowing = null;
      server.emit(eventName, req, socket, bodyHead);
    } else {
      socket.destroy();
    }
  }
}

这时候nodejs会判断请求是不是connect或者协议升级的upgrade请求,是的话继续判断是否有处理该事件的函数,没有则关闭连接,否则触发对应的事件进行处理。所以这时候nodejs会触发connect方法。connect事件的处理逻辑正如我们开始给出的例子中那样。我们首先和真正的服务器建立tcp连接,然后返回响应头给客户端,后续客户就可以和真正的服务器真正进行tls握手和https通信了。这就是nodejs中connect的原理和实现。

不过在代码中我们发现一个好玩的地方。那就是在触发connect事件的时候,nodejs给回调函数传入的参数。

代码语言:javascript
复制
server.emit('connect', req, socket, bodyHead);

第一第二个参数没什么特别的,但是第三个参数就有意思了,bodyHead代表的是http connect请求中除了请求行和http头之外的数据。因为nodejs解析完http头后就不继续处理了。把剩下的数据交给了用户。我们来做一些好玩的事情。

代码语言:javascript
复制
const http = require('http');
const net = require('net');
const { URL } = require('url');

const proxy = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('okay');
});
proxy.on('connect', (req, clientSocket, head) => {
  const { port, hostname } = new URL(`http://${req.url}`);
  const serverSocket = net.connect(port || 80, hostname, () => {
    clientSocket.write('HTTP/1.1 200 Connection Established\r\n' +
                    'Proxy-agent: Node.js-Proxy\r\n' +
                    '\r\n');
    // 把connect请求剩下的数据转发给服务器               
    serverSocket.write(head);
    serverSocket.pipe(clientSocket);
    clientSocket.pipe(serverSocket);
  });
});

proxy.listen(1337, '127.0.0.1', () => {
  const net = require('net');
  const body = 'GET http://www.baidu.com:80 HTTP/1.1\r\n\r\n';
  const length = body.length;
  const socket = net.connect({host: '127.0.0.1', port: 1337});
  socket.write(`CONNECT www.baidu.com:80 HTTP/1.1\r\n\r\n${body}`);
  socket.setEncoding('utf-8');
  socket.on('data', (chunk) => {
   console.log(chunk)
  });
});

我们新建一个socket,然后自己构造http connect报文,并且在http行后面加一个额外的字符串,这个字符串是两一个http请求。当nodejs服务器收到connect请求后,我们在connect事件的处理函数中,把connect请求多余的那一部分数据传给真正的服务器。这样就节省了发送一个请求的时间。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-11-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 编程杂技 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
SSL 证书
腾讯云 SSL 证书(SSL Certificates)为您提供 SSL 证书的申请、管理、部署等服务,为您提供一站式 HTTPS 解决方案。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档