分析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官方的一个例子。
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解析器处理,
// 连接上有数据到来
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。
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。
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。
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给回调函数传入的参数。
server.emit('connect', req, socket, bodyHead);
第一第二个参数没什么特别的,但是第三个参数就有意思了,bodyHead代表的是http connect请求中除了请求行和http头之外的数据。因为nodejs解析完http头后就不继续处理了。把剩下的数据交给了用户。我们来做一些好玩的事情。
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请求多余的那一部分数据传给真正的服务器。这样就节省了发送一个请求的时间。