HTTP代理原理及实现
HTTP 代理存在两种形式:
- 普通代理
- 隧道代理
普通代理
HTTP 客户端向代理发送请求报文,代理服务器需要正确地处理请求和连接(例如正确处理 Connection: keep-alive),同时向服务器发送请求,并将收到的响应转发给客户端。
隧道代理
参考链接:
node中使用http.request发起请求
发起get请求
js
复制代码
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, 源码如下:
js
复制代码
function get(input, options, cb) { const req = request(input, options, cb); req.end(); return req; }
发起post请求
js
复制代码
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实现
javascript
复制代码
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
, 它们通常出现在如下代码中:
请求:
javascript
复制代码
ClientRequest = http.request(options, (IncomingMessageRes) => {}); ClientRequest.send(data);
响应:
javascript
复制代码
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实现
js
复制代码
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的使用
安装:
shell
复制代码
yarn add https-proxy-agent
使用:
js
复制代码
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();
源码解读
大体结构
js
复制代码
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函数签名:
js
复制代码
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
向代理服务器发起请求:
sql
复制代码
CONNECT 实际请求地址 HTTP/1.1
socket
监听响应, 请求返回状态码为200, 则代表链接建立, 将socket
传递给cb
, 交给http|https
进行后续的请求
发起请求
一个请求示例:
makefile
复制代码
CONNECT www.arrow.com:443 HTTP/1.1 Host: www.arrow.com Connection: close
js
复制代码
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');
读取数据
js
复制代码
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可能是为了兼容以前的版本, 为了方便理解上面的代码可以简化成:
js
复制代码
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
继续处理请求.
js
复制代码
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); }