1 概述

node-http-proxy 模塊用於轉發 http 請求,其實現的大致原理為使用 http 或 https 模塊搭建 node 代理伺服器,將客戶端發送的請求數據轉發到目標伺服器,再將響應輸送到客戶端。

2 實現

2.1 整體流程

同 koa 的中間件機制相仿,node-http-proxy 模塊內部組裝任務隊列,在請求轉發的過程中,將任務隊列中的處理函數逐個執行。處理函數的意義通常是封裝消息頭,當然,最後一個處理函數用於轉發請求、輸出響應。

同常見的 ajax 模塊,node-http-proxy 模塊接受全局配置的 options,同時,在某個具體的請求中,又接受特定的配置項 opts。而客戶端發送的請求可能是 http, https 請求,也可能是 websocket 請求,node-http-proxy 模塊必須實現對這兩類請求的不同處理。

上述三點,實際的代碼體現在 createRightProxy 高階函數中:

// 參數 type 用於區分請求類型,web 為普通 http, https 請求,ws 為 websocket 請求
function createRightProxy(type) {

// 參數 options 為全局配置項
return function(options) {
return function(req, res /*, [head], [opts] */) {
// passes 任務隊列
var passes = (type === ws) ? this.wsPasses : this.webPasses,
args = [].slice.call(arguments),
cntr = args.length - 1,
head, cbl;

// 解析回調函數
if(typeof args[cntr] === function) {
cbl = args[cntr];
cntr--;
}

// 混入該請求中特定的配置項 opts
var requestOptions = options;
if(
!(args[cntr] instanceof Buffer) &&
args[cntr] !== res
) {
requestOptions = extend({}, options);
extend(requestOptions, args[cntr]);
cntr--;
}

// head
if(args[cntr] instanceof Buffer) {
head = args[cntr];
}

// 請求的目標地址
[target, forward].forEach(function(e) {
if (typeof requestOptions[e] === string)
requestOptions[e] = parse_url(requestOptions[e]);
});

if (!requestOptions.target && !requestOptions.forward) {
return this.emit(error, new Error(Must provide a proper URL as target));
}

// 挨個執行任務隊列,處理消息頭,轉發請求
for(var i=0; i < passes.length; i++) {
if(passes[i](req, res, requestOptions, head, this, cbl)) {
break;
}
}
};
};
}

因此,createRightProxy(web)(options), createRightProxy(ws)(options) 就能用於創建實際的請求轉發函數。在 node-http-proxy 模塊中,這兩個函數分別表現為 ProxyServer 實例的 web, ws 方法。其中,proxyServer.web 方法作為 http 或 https 伺服器 listen 方法的回調函數,proxyServer.ws 方法作為 upgrade 事件的綁定函數,從而能對接上客戶端 ajax 請求、websocket 請求的執行時機。

function ProxyServer(options) {
// ...

// 創建轉發 http, https; websocket 請求的處理函數
this.web = this.proxyRequest = createRightProxy(web)(options);
this.ws = this.proxyWebsocketRequest = createRightProxy(ws)(options);

// 任務隊列,用於處理消息頭、轉發請求
this.webPasses = Object.keys(web).map(function(pass) {// this.web 方法執行過程中調用的任務隊列
return web[pass];
});
this.wsPasses = Object.keys(ws).map(function(pass) {// this.ws 方法執行過程中調用的任務隊列
return ws[pass];
});

// ...
}

ProxyServer.prototype.listen = function(port, hostname) {
var self = this,
closure = function(req, res) { self.web(req, res); };// 轉發 http, https 請求

this._server = this.options.ssl ?
https.createServer(this.options.ssl, closure) :
http.createServer(closure);

// 轉發 websocket 請求
if(this.options.ws) {
this._server.on(upgrade, function(req, socket, head) { self.ws(req, socket, head); });
}

this._server.listen(port, hostname);

return this;
};

以上不涉及任務隊列的具體實現,卻構成了 node-http-proxy 模塊整體處理流程。除外而外,ProxyServer 還提供 before(type, passName, callback), after(type, passName, callback) 原型方法,用於在任務隊列的某個具體處理函數之前或之後插入一個處理函數 callback。

2.2 http, https 請求

this.webPasses 任務隊列包含如下四種處理函數:deleteLength, timeout, XHeaders, stream。

  1. deleteLength 函數:針對 DELETE 或 OPTIONS,且 headers[content-length] 未設置的情形,將 headers[content-length] 置為 0,並刪除 headers[transfer-encoding] 消息頭。
  2. timeout 函數:若設置了 options.timeout,調用 req.socket.setTimeout(options.timeout) 設置超時時間。
  3. XHeaders 函數:設置 x-forwarded-for, x-forwarded-port, x-forwarded-proto, x-forwarded-host 消息頭,包含客戶端和代理伺服器的地址、埠、協議等內容(以 , 拼接 req.headers 同名屬性即客戶端內容、和代理伺服器內容)。其中,x-forwarded-host 消息頭只包含 req.headers.host,即代理伺服器的主機名。由配置項 options.xfwd 啟用 x-forwarded-* 消息頭的設置。
  4. stream 函數:實際轉發請求的處理函數。下文將作詳解。

stream 函數的處理流程為:

  1. 調用 common.setupOutgoing 方法生成代理請求的配置項。
  2. 通過 options.forward, options.target 區分 forward 和 target 兩種模式。在 forward 模式下,只通過代理請求轉發到目標伺服器,輸送給客戶端的仍是代理伺服器的響應。target 模式下,不只可以處理目標伺服器的響應,且可監聽許多事件對代理請求等作出處理。forward 和 target 模式可以並行存在,即同時指定 options.forward, options.target。

首先,common.setupOutgoing 的實現如下:

/**
* 生成代理請求的配置項,將作為 http.request 的參數
* @param {object} outgoing 即 options.ssl 或 {}
* @param {object} options 即 options
* @param {object} req 即實際的請求
* @param {string|undefined} forward 用於區分 forward 和 target 模式。值為 forward 或 undefined
*/
common.setupOutgoing = function(outgoing, options, req, forward) {
outgoing.port = options[forward || target].port ||
(isSSL.test(options[forward || target].protocol) ? 443 : 80);

// http://nodejs.cn/api/http.html#http_http_request_options_callback
// host, host: 目標伺服器的域名或 IP 地址
// socketPath: Unix 域 Socket(使用 host:port 或 socketPath)
// ca: ca 證書
[host, hostname, socketPath, pfx, key,
passphrase, cert, ca, ciphers, secureProtocol].forEach(
function(e) { outgoing[e] = options[forward || target][e]; }
);

// 請求方法
outgoing.method = options.method || req.method;

// 請求頭
outgoing.headers = extend({}, req.headers);
if (options.headers){
extend(outgoing.headers, options.headers);
}

// 基本身份驗證,如 user:password 用來計算 Authorization 請求頭
if (options.auth) {
outgoing.auth = options.auth;
}

if (options.ca) {
outgoing.ca = options.ca;
}

if (isSSL.test(options[forward || target].protocol)) {
outgoing.rejectUnauthorized = (typeof options.secure === "undefined") ? true : options.secure;
}

// http://nodejs.cn/api/http.html#http_new_agent_options
// 長連接時設置 options.agent = { keepAlive, keepAliveMsecs }
outgoing.agent = options.agent || false;
outgoing.localAddress = options.localAddress;

// 不是長連接,設置 outgoing.headers.connection 請求頭
if (!outgoing.agent) {
outgoing.headers = outgoing.headers || {};
if (typeof outgoing.headers.connection !== string
|| !upgradeHeader.test(outgoing.headers.connection)
) { outgoing.headers.connection = close; }
}

// 最終的請求路徑由 options[forward|target], req.url 拼接產生,可根據 options 配置設定某項是否啟用
var target = options[forward || target];
var targetPath = target && options.prependPath !== false
? (target.path || ) : ;
var outgoingPath = !options.toProxy
? (url.parse(req.url).path || ) : req.url;
outgoingPath = !options.ignorePath ? outgoingPath : ;
outgoing.path = common.urlJoin(targetPath, outgoingPath);

if (options.changeOrigin) {
outgoing.headers.host =
// 通過 requires-port 模塊校驗在使用某種協議的情況下,是否需要在 url 上拼接埠號
required(outgoing.port, options[forward || target].protocol) && !hasPort(outgoing.host)
? outgoing.host + : + outgoing.port
: outgoing.host;
}
return outgoing;
};

其次,stream 的實現如下:

  1. 調用 [http|https].request(outgoing) 創建代理請求。outgoing 由 common.setupOutgoing 函數獲得。
  2. 調用 (options.buffer || req).pipe(forwardReq) 方法轉發代理請求。
  3. target 模式下,調用 web-outgoing 模塊中的函數處理代理響應。

function stream(req, res, options, _, server, clb) {
server.emit(start, req, res, options.target || options.forward);

// options.followRedirects 是否使用 follow-redirects 重定向
var agents = options.followRedirects ? followRedirects : nativeAgents;
var http = agents.http;
var https = agents.https;

if(options.forward) {
// 生成代理請求
var forwardReq = (options.forward.protocol === https: ? https : http).request(
common.setupOutgoing(options.ssl || {}, options, req, forward)
);

var forwardError = createErrorHandler(forwardReq, options.forward);
req.on(error, forwardError);
forwardReq.on(error, forwardError);

// 轉發代理請求
(options.buffer || req).pipe(forwardReq);

// 非 target 模式,返迴響應
if(!options.target) { return res.end(); }
}

// 生成代理請求
var proxyReq = (options.target.protocol === https: ? https : http).request(
common.setupOutgoing(options.ssl || {}, options, req)
);

proxyReq.on(socket, function(socket) {
if(server) { server.emit(proxyReq, proxyReq, req, res, options); }
});

if(options.proxyTimeout) {
proxyReq.setTimeout(options.proxyTimeout, function() {
proxyReq.abort();
});
}

req.on(aborted, function () {
proxyReq.abort();
});

var proxyError = createErrorHandler(proxyReq, options.target);
req.on(error, proxyError);
proxyReq.on(error, proxyError);

function createErrorHandler(proxyReq, url) {
return function proxyError(err) {
if (req.socket.destroyed && err.code === ECONNRESET) {
server.emit(econnreset, err, req, res, url);
return proxyReq.abort();
}

if (clb) {
clb(err, req, res, url);
} else {
server.emit(error, err, req, res, url);
}
}
}

// 轉發代理請求
(options.buffer || req).pipe(proxyReq);

proxyReq.on(response, function(proxyRes) {
if(server) { server.emit(proxyRes, proxyRes, req, res); }

// 調用 web-outgoing 模塊中的函數處理代理響應
if(!res.headersSent && !options.selfHandleResponse) {
for(var i=0; i < web_o.length; i++) {
if(web_o[i](req, res, proxyRes, options)) { break; }
}
}

// http://nodejs.cn/api/http.html#http_response_finished
if (!res.finished) {
// 通過事件處理代理響應
proxyRes.on(end, function () {
if (server) server.emit(end, req, res, proxyRes);
});

// 由 node-http-proxy 模塊處理代理響應的情境下,返迴響應
if (!options.selfHandleResponse) proxyRes.pipe(res);
} else {
if (server) server.emit(end, req, res, proxyRes);
}
});
}

最後,再來看一下 web-outgoing 模塊對代理響應的處理(實現查看源碼):

  1. removeChunked 函數:當使用 http/1.0 時(通過 req.httpVersion === 1.0 判斷,客戶端決定),移除代理響應的 headers[transfer-encoding]。
  2. setConnection 函數:當使用 http/1.0 時,代理響應的 headers.connection 設為 req.headers.connection || close;當使用非 http/2.0 時,且 proxyRes.headers.connection 為否,將 代理響應的 headers.connection 設為 req.headers.connection || keep-alive。
  3. setRedirectHostRewrite 函數:根據 options.hostRewrite 或 options.autoRewrite 或 options.protocolRewrite 重寫重定向地址 proxyRes.headers.location。代理相應的狀態碼須匹配 /^201|30(1|2|7|8)$/ 正則,且 proxyRes.headers.location 須與目標伺服器同域名。
  4. writeHeaders 函數:將代理響應的消息頭寫入實際響應 res 的消息頭中。下文將作詳解。
  5. writeStatusCode 函數:將代理響應的 statusCode, statusMessage 寫入返回給客戶端的響應 res 中。

setRedirectHostRewrite 函數的代碼實現:

function writeHeaders(req, res, proxyRes, options) {
var rewriteCookieDomainConfig = options.cookieDomainRewrite,
rewriteCookiePathConfig = options.cookiePathRewrite,
preserveHeaderKeyCase = options.preserveHeaderKeyCase,
rawHeaderKeyMap,
setHeader = function(key, header) {
if (header == undefined) return;
if (rewriteCookieDomainConfig && key.toLowerCase() === set-cookie) {
header = common.rewriteCookieProperty(header, rewriteCookieDomainConfig, domain);
}
if (rewriteCookiePathConfig && key.toLowerCase() === set-cookie) {
header = common.rewriteCookieProperty(header, rewriteCookiePathConfig, path);
}
res.setHeader(String(key).trim(), header);
};

if (typeof rewriteCookieDomainConfig === string) { //also test for
rewriteCookieDomainConfig = { *: rewriteCookieDomainConfig };
}

if (typeof rewriteCookiePathConfig === string) { //also test for
rewriteCookiePathConfig = { *: rewriteCookiePathConfig };
}

// http://nodejs.cn/api/http.html#http_message_rawheaders
if (preserveHeaderKeyCase && proxyRes.rawHeaders != undefined) {
rawHeaderKeyMap = {};
for (var i = 0; i < proxyRes.rawHeaders.length; i += 2) {
var key = proxyRes.rawHeaders[i];
rawHeaderKeyMap[key.toLowerCase()] = key;
}
}

Object.keys(proxyRes.headers).forEach(function(key) {
var header = proxyRes.headers[key];
if (preserveHeaderKeyCase && rawHeaderKeyMap) {
key = rawHeaderKeyMap[key] || key;
}
setHeader(key, header);
});
}

// 根據 options.cookieDomainRewrite, options.cookiePathRewrite 重寫 res.headers.cookie 中的 domain, path 屬性
// cookie.domain 表示 cookie 所在的域
// cookie.path 表示 cookie 所在的目錄
// 參考 [理解cookie的path和domain屬性](https://www.cnblogs.com/chris-oil/p/3869803.html)
common.rewriteCookieProperty = function rewriteCookieProperty(header, config, property) {
if (Array.isArray(header)) {
return header.map(function (headerElement) {
return rewriteCookieProperty(headerElement, config, property);
});
}
return header.replace(new RegExp("(;\s*" + property + "=)([^;]+)", i), function(match, prefix, previousValue) {
var newValue;
if (previousValue in config) {
newValue = config[previousValue];
} else if (* in config) {
newValue = config[*];
} else {
return match;
}
if (newValue) {
return prefix + newValue;
} else {
return ;
}
});
};

2.3 websocket 請求

this.wsPasses 任務隊列包含如下四種處理函數:checkMethodAndHeader, XHeaders, stream。

  1. checkMethodAndHeader 函數:websocket 請求的請求方式必須是 get,且 headers.upgrade 請求頭必須是 websocket,checkMethodAndHeader 函數用於校驗請求方式和 headers.upgrade 請求頭。
  2. XHeaders 函數:設置 x-forwarded-for, x-forwarded-port, x-forwarded-proto 消息頭,包含客戶端和代理伺服器的地址、埠、協議等內容(以 , 拼接 req.headers 同名屬性即客戶端內容、和代理伺服器內容)。由配置項 options.xfwd 啟用 x-forwarded-* 消息頭的設置。
  3. stream 函數:實際轉發請求的處理函數。下文將作詳解。

stream 函數的處理流程為:

  1. 調用 common.setupOutgoing 方法生成代理請求的配置項。
  2. 調用 [http|https].request(outgoing) 創建代理請求 proxyReq。
  3. 調用 proxyReq.end 發送代理請求。
  4. 監聽 response 事件,修改消息頭後將響應發送給客戶端。
  5. 監聽 upgrade 事件,更換協議後,調用 proxySocket.pipe(socket).pipe(proxySocket) 再次發送代理請求。

common.setupSocket = function(socket) {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 0);

return socket;
};

function stream(req, socket, options, head, server, clb) {
// 添加請求頭內容
var createHttpHeader = function(line, headers) {
return Object.keys(headers).reduce(function (head, key) {
var value = headers[key];

if (!Array.isArray(value)) {
head.push(key + : + value);
return head;
}

for (var i = 0; i < value.length; i++) {
head.push(key + : + value[i]);
}
return head;
}, [line])
.join(
) +

;
}

common.setupSocket(socket);

if (head && head.length) socket.unshift(head);

// 創建代理請求
var proxyReq = (common.isSSL.test(options.target.protocol) ? https : http).request(
common.setupOutgoing(options.ssl || {}, options, req)
);

if (server) { server.emit(proxyReqWs, proxyReq, req, socket, options, head); }

proxyReq.on(error, onOutgoingError);
proxyReq.on(response, function (res) {
// 屬性響應到客戶端
if (!res.upgrade) {
socket.write(createHttpHeader(HTTP/ + res.httpVersion + + res.statusCode + + res.statusMessage, res.headers));
res.pipe(socket);
}
});

proxyReq.on(upgrade, function(proxyRes, proxySocket, proxyHead) {
proxySocket.on(error, onOutgoingError);

proxySocket.on(end, function () {
server.emit(close, proxyRes, proxySocket, proxyHead);
});

socket.on(error, function () {
proxySocket.end();
});

common.setupSocket(proxySocket);

if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);

socket.write(createHttpHeader(HTTP/1.1 101 Switching Protocols, proxyRes.headers));

proxySocket.pipe(socket).pipe(proxySocket);// 再次發送代理請求?

server.emit(open, proxySocket);
server.emit(proxySocket, proxySocket);
});

return proxyReq.end(); // 發送代理請求

function onOutgoingError(err) {
if (clb) {
clb(err, req, socket);
} else {
server.emit(error, err, req, socket);
}
socket.end();
}
}

3 應用

3.1 http-proxy-middleware

參見 http-proxy-middleware 源碼解讀。

3.1 nokit-filter-proxy

nokit-filter-proxy 庫用於為 nokit 伺服器添加代理功能。鑒於前端構建工具 dawn 使用 nokit 搭建本地調試伺服器,nokit-filter-proxy 庫也用於為 dn-middleware-server 中間件實現代理功能。

同 http-proxy-middleware 庫,nokit-filter-proxy 藉助 node-http-proxy 實現伺服器代理的都是先校驗請求路徑是否匹配轉發策略,攔截並轉發請求。nokit-filter-proxy 通過綁定 onRequest 事件函數,實現請求的攔截和轉發。詳見源碼:

var httpProxy = require("http-proxy");

function ProxyFilter(server) {
var self = this;
var utils = self.utils = server.require("$./core/utils");

// proxy 配置,作為請求路徑轉發規則
self.configs = server.configs.proxy || {};

// 作為代理伺服器的配置項
self.configs.options = self.configs.options || {};

// 代理請求設置 x-forwarded-for, x-forwarded-port, x-forwarded-proto, x-forwarded-host 消息頭
if (utils.isNull(self.configs.options.xfwd)) {
self.configs.options.xfwd = true;
}

// 代理請求設置 headers.host 消息頭
if (utils.isNull(self.configs.options.changeOrigin)) {
self.configs.options.changeOrigin = true;
}

// 請求路徑轉發規則,key - value 形式,key 為客戶端請求路徑正則,value 為目標伺服器路徑
self.configs.rules = self.configs.rules || {};

// 創建代理伺服器
self.proxy = httpProxy.createProxyServer(self.configs.options);

// 轉發代理請求前,使用 headers 配置修改代理請求的消息頭
self.onProxyReqHandler = self.onProxyReqHandler.bind(self);
self.proxy.on("proxyReq", self.onProxyReqHandler);
};

ProxyFilter.prototype.onProxyReqHandler = function(proxyReq, req, res, options) {
var self = this;
if (!self.configs.headers) return;
self.utils.each(self.configs.headers, function(name, value) {
proxyReq.setHeader(name, value);
});
};

// self.matchRule 根據 rules 配置,解析出請求路徑轉發規則
ProxyFilter.prototype.matchRule = function(url) {
var self = this;
var rule = null;
self.utils.each(self.configs.rules, function(exprText, target) {
var expr = new RegExp(exprText);
if (expr.test(url)) {
var urlParts = expr.exec(url);
rule = {
url: urlParts.length > 1 ? urlParts[1] : url,
target: target
};
}
});
return rule;
};

ProxyFilter.prototype.onRequest = function(context, next) {
var self = this;
var res = context.res,
req = context.req;

var rule = self.matchRule(req.url);
if (!rule) return next();
req.url = rule.url || "/";

// 轉發代理請求
self.proxy.web(req, res, {
"target": rule.target
});
};

4 後記

這兩篇文章都是在筆者整理完 proxy 設計模式後整理的。鑒於本人水平有限,文章難免錯謬,仍望讀者不吝賜教。


推薦閱讀:
相關文章