HTTP代理原理及实现
HTTP 代理存在两种形式:
- 普通代理
- 隧道代理
普通代理
HTTP 客户端向代理发送请求报文,代理服务器需要正确地处理请求和连接(例如正确处理 Connection: keep-alive),同时向服务器发送请求,并将收到的响应转发给客户端。
隧道代理
参考链接:
node中使用http.request发起请求
发起get请求
const https = require('https');
const url = require('url')
// 也可以直接传入url字符串, 但是在内部也会被url.parse()解析
const options = url.parse('https://nodejs.org/');
// 访问https协议的网站用https模块
const req = https.request(options, (res) => {
...
});
req.end();
注意,上面代码中,req.end() 必须被调用,即使没有在请求体内写入任何数据,也必须调用。因为这表示已经完成HTTP请求
否则导致超时, 报Error: socket hang up错误
也可以使用https.get, 它的内部也是使用https.request, 源码如下:
function get(input, options, cb) {
const req = request(input, options, cb);
req.end();
return req;
}
发起post请求
const postData = querystring.stringify({
'msg': 'Hello World!'
});
const options = {
hostname: 'www.google.com',
port: 80,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
res.on('end', () => {
console.log('No more data in response.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// write data to request body
req.write(postData);
req.end();
发起请求时需要注意的几个header
- 发送 'Connection: keep-alive' 会通知 Node.js 与服务器的连接应该持续到下一个请求。
- 发送 'Content-Length' 请求头会禁用默认的分块编码
- 发送 'Expect' 请求头会立即发送请求头。
- 发送授权请求头会覆盖
auth参数
请求的事件触发顺序
在成功的请求中,会按以下顺序触发以下事件:
socket事件response事件- res 对象上任意次数的
data事件(如果响应主体为空,则根本不会触发 -data事件,例如在大多数重定向中) - res 对象上的
end事件
- res 对象上任意次数的
close事件
如果出现连接错误,则触发以下事件:
socket事件error事件close事件
如果在连接成功之前调用 req.abort(),则按以下顺序触发以下事件:
socket事件- (在这里调用 req.abort())
abort事件error事件并带上错误信息 'Error: socket hang up' 和错误码 'ECONNRESET'close事件
如果在响应被接收之后调用 req.abort(),则按以下顺序触发以下事件:
socket事件response事件- res 对象上任意次数的
data事件 - (在这里调用 req.abort())
abort事件- res 对象上的
aborted事件 close事件- res 对象上的
end事件 - res 对象上的
close事件
http.request(options, callback)中的callback就是当response事件触发时运行.socket事件: 将套接字分配给此请求后触发。
HTTP普通代理服务器nodejs实现
var http = require('http');
var net = require('net');
var url = require('url');
function request(cIncomeReq, cRes) {
var u = url.parse(cIncomeReq.url);
var options = {
hostname : u.hostname,
port : u.port || 80,
path : u.path,
method : cIncomeReq.method,
headers : cIncomeReq.headers
};
var pReq = http.request(options, function(pIncomeRes) {
cRes.writeHead(pIncomeRes.statusCode, pIncomeRes.headers);
pIncomeRes.pipe(cRes);
}).on('error', function(e) {
cRes.end();
});
cIncomeReq.pipe(pReq);
}
http.createServer().on('request', request).listen(8888, '0.0.0.0');
这里需要注意的是管道的写法, http.IncomingMessage, http.ClientRequest, http.ServerResponse都是继承自Stream, 它们通常出现在如下代码中:
请求:
ClientRequest = http.request(options, (IncomingMessageRes) => {});
ClientRequest.send(data);
响应:
http.createServer().on('request', (IncomingMessageReq, ServerResponse) => {})
ClientRequest, ServerResponse可以看作是WriteStream, 主动发送数据, IncomingMessage则是ReadStream, 被动接收数据.
所以cIncomeReq.pipe(pReq)可以理解为客户端发送的代理服务器的请求数据(cIncomeReq)通过(pReq)被转发到目标服务器.
pIncomeRes.pipe(cRes)可以理解为目标服务器响应的数据(pIncomeRes)通过(cRes)被转发会客户端.
以上代码运行后,会在本地 8888 端口开启 HTTP 代理服务,这个服务从请求报文中解析出请求 URL 和其他必要参数,新建到服务端的请求,并把代理收到的请求转发给新建的请求,最后再把服务端响应返回给浏览器。修改浏览器的 HTTP 代理(我用的是ProxySwitchy来修改的代理)为 127.0.0.1:8888 后再访问 HTTP 网站,代理可以正常工作。
但是,使用我们这个代理服务后,HTTPS 网站完全无法访问,这是为什么呢?
HTTP隧道代理服务器nodejs实现
var http = require('http');
var net = require('net');
var url = require('url');
function connect(cReq, cSock) {
var u = url.parse('http://' + cReq.url);
var pSock = net.connect(u.port, u.hostname, function() {
cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
pSock.pipe(cSock);
}).on('error', function(e) {
cSock.end();
});
cSock.pipe(pSock);
}
http.createServer().on('connect', connect).listen(8888, '0.0.0.0');
https-proxy-agent源码解读
或者我们可以直接使用https-proxy-agent
https-proxy-agent的使用
安装:
yarn add https-proxy-agent
使用:
const https = require('https');
const HttpsProxyAgent = require('https-proxy-agent');
// 代理服务器地址, 代理协议可以是http也可以https
const proxyEndpoint = 'https://115.204.25.88:4217';
const agent = new HttpsProxyAgent(proxyEndpoint);
const options = {
agent,
};
// 使用http.get或者https.get都可以, 取决访问网址协议是http还是https
const req = https.get(url, options, (res) => {
res.pipe(process.stdout);
});
req.end();
源码解读
大体结构
var Agent = require('agent-base');
var inherits = require('util').inherits;
function HttpsProxyAgent(opts) { ... };
inherits(HttpsProxyAgent, Agent);
HttpsProxyAgent.prototype.callback = function connect(req, opts, fn) { ... };
HttpsProxyAgent继承自agent-base, agent-base作用是创建http.Agent实例, 这里我们介绍它其中一种用法:
自定义它的callback属性, 下面是callback函数签名:
callback(http.ClientRequest req, Object options, Function cb) → undefined
req可以获取headers和path等和请求相关的信息. options包含了在调用http.request()/https.request()时所传入的参数, options是已经格式化了的, 可以直接作为参数传入net.connect()/tls.connect(). 在这个函数中你需要创建一个socket, 然后传递给cb, HTTP请求将继续进行.
connect
https-proxy-agent使用的隧道代理的方式, 所以它的主要流程是:
- 判断代理服务器的地址是否为https协议, 使用
net或者tls来创建一个socket. - 使用创建的
socket向代理服务器发起请求:
CONNECT 实际请求地址 HTTP/1.1
socket监听响应, 请求返回状态码为200, 则代表链接建立, 将socket传递给cb, 交给http|https进行后续的请求
发起请求
一个请求示例:
CONNECT www.arrow.com:443 HTTP/1.1
Host: www.arrow.com
Connection: close
var hostname = opts.host + ':' + opts.port;
var msg = 'CONNECT ' + hostname + ' HTTP/1.1\r\n';
var headers = Object.assign({}, proxy.headers);
// 如果代理服务器需要认证, 需要加上认证header
if (proxy.auth) {
headers['Proxy-Authorization'] =
'Basic ' + Buffer.from(proxy.auth).toString('base64');
}
// the Host header should only include the port
// number when it is a non-standard port
var host = opts.host;
if (!isDefaultPort(opts.port, opts.secureEndpoint)) {
host += ':' + opts.port;
}
headers['Host'] = host;
headers['Connection'] = 'close';
Object.keys(headers).forEach(function(name) {
msg += name + ': ' + headers[name] + '\r\n';
});
socket.write(msg + '\r\n');
读取数据
if (socket.read) {
read();
} else {
socket.once('data', ondata);
}
function ondata(b) {
buffers.push(b);
buffersLength += b.length;
var buffered = Buffer.concat(buffers, buffersLength);
var str = buffered.toString('ascii');
if (!~str.indexOf('\r\n\r\n')) {
// keep buffering
debug('have not received end of HTTP headers yet...');
if (socket.read) {
read();
} else {
socket.once('data', ondata);
}
return;
}
...
}
关于socket.read我查询了node10.5.3的API是没有这个接口的, socket.read可能是为了兼容以前的版本, 为了方便理解上面的代码可以简化成:
socket.once('data', ondata);
var buffers = [];
var buffersLength = 0;
function ondata(b) {
buffers.push(b);
buffersLength += b.length;
var buffered = Buffer.concat(buffers, buffersLength);
var str = buffered.toString('ascii');
// ~表示取反码, -1的反码为0, !~-1 === true, 其他情况!~i === false.
// !~str.indexOf('\r\n\r\n') 等价于 str.indexOf('\r\n\r\n') === -1
// 也就是如果遇到`\r\n\r\n`代表数据接收完成
if (!~str.indexOf('\r\n\r\n')) {
// keep buffering
debug('have not received end of HTTP headers yet...');
socket.once('data', ondata);
return;
}
...
}
返回socket给cb
这个步骤是在与代理服务器建立隧道成功后, 需要返回一个socket给cb, 让http|https继续处理请求.
if (200 == statusCode) {
// 200 Connected status code!
var sock = socket;
// nullify the buffered data since we won't be needing it
buffers = buffered = null;
if (opts.secureEndpoint) {
// since the proxy is connecting to an SSL server, we have
// to upgrade this socket connection to an SSL connection
debug(
'upgrading proxy-connected socket to TLS connection: %o',
opts.host
);
// 这里当是https协议访问目标网站时, 需要升级此套接字
// 目前还没有搞明白为什么要升级, 已经以下升级代码的含义
opts.socket = socket;
opts.servername = opts.servername || opts.host;
opts.host = null;
opts.hostname = null;
opts.port = null;
sock = tls.connect(opts);
}
cleanup();
fn(null, sock);
}