在 Node.js 上實現一個簡單的 HTTP 代理程序還是非常簡單的,本文章的例子的核心代碼只有 60 多行,只要理解 內置 http 模塊 的基本用法即可,具體請看下文。
接口設計與相關技術
使用 http.createServer() 創建的 HTTP 服務器,處理請求的函數格式一般為 function (req, res) {} (下文簡稱為 requestHandler ),其接收兩個參數,分別為 http.IncomingMessage 和 http.ServerResponse 對象,我們可以通過這兩個對象來取得請求的所有信息并對它進行響應。
主流的 Node.js Web 框架的中間件(比如 connect )一般都有兩種形式:
中間件不需要任何初始化參數,則其導出結果為一個 requestHandler
中間件需要初始化參數,則其導出結果為中間件的初始化函數,執行該初始化函數時,傳入一個 options 對象,執行后返回一個 requestHandler
為了使代碼更規范,在本文例子中,我們將反向代理程序設計成一個中間件的格式,并使用以上第二種接口形式:
// 生成中間件
const handler = reverseProxy({
// 初始化參數,用于設置目標服務器列表
servers: ["127.0.0.1:3001", "127.0.0.1:3002", "127.0.0.1:3003"]
});
// 可以直接在 http 模塊中使用
const server = http.createServer(handler);
// 作為中間件在 connect 模塊中使用
app.use(handler);
說明:
上面的代碼中, reverseProxy 是反向代理服務器中間件的初始化函數,它接受一個對象參數, servers 是后端服務器地址列表,每個地址為 IP 地址:端口 這樣的格式
執行 reverseProxy() 后返回一個 function (req, res) {} 這樣的函數,用于處理 HTTP 請求,可作為 http.createServer() 和 connect 中間件的 app.use() 的處理函數
當接收到客戶端請求時,按順序循環從 servers 數組中取出一個服務器地址,將請求代理到這個地址的服務器上
服務器在接收到 HTTP 請求后,首先需要發起一個新的 HTTP 請求到要代理的目標服務器,可以使用 http.request() 來發送請求:
const req = http.request(
{
hostname: "目標服務器地址",
port: "80",
path: "請求路徑",
headers: {
"x-y-z": "請求頭"
}
},
function(res) {
// res 為響應對象
console.log(res.statusCode);
}
);
// 如果有請求體需要發送,使用 write() 和 end()
req.end();
要將客戶端的請求體( Body 部分,在 POST 、 PUT 這些請求時會有請求體)轉發到另一個服務器上,可以使用 Stream 對象的 pipe() 方法,比如:
// req 和 res 為客戶端的請求和響應對象
// req2 和 res2 為服務器發起的代理請求和響應對象
// 將 req 收到的數據轉發到 req2
req.pipe(req2);
// 將 res2 收到的數據轉發到 res
res2.pipe(res);
說明:
req 對象是一個 Readable Stream (可讀流),通過 data 事件來接收數據,當收到 end事件時表示數據接收完畢
res 對象是一個 Writable Stream (可寫流),通過 write() 方法來輸出數據, end() 方法來結束輸出
為了簡化從 Readable Stream 監聽 data 事件來獲取數據并使用 Writable Stream 的 write() 方法來輸出,可以使用 Readable Stream 的 pipe() 方法
以上只是提到了實現 HTTP 代理需要的關鍵技術,相關接口的詳細文檔可以參考這里: https://nodejs.org/api/http.html#http_http_request_options_callback
當然為了實現一個接口友好的程序,往往還需要很多 額外 的工作,具體請看下文。
簡單版本
以下是實現一個簡單 HTTP 反向代理服務器的各個文件和代碼(沒有任何第三方庫依賴), 為了使代碼更簡潔,使用了一些最新的 ES 語法特性,需要使用 Node v8.x 最新版本來運行 :
文件 proxy.js :
const http = require("http");
const assert = require("assert");
const log = require("./log");
/** 反向代理中間件 */
module.exports = function reverseProxy(options) {
assert(Array.isArray(options.servers), "options.servers 必須是數組");
assert(options.servers.length > 0, "options.servers 的長度必須大于 0");
// 解析服務器地址,生成 hostname 和 port
const servers = options.servers.map(str => {
const s = str.split(":");
return { hostname: s[0], port: s[1] || "80" };
});
// 獲取一個后端服務器,順序循環
let ti = 0;
function getTarget() {
const t = servers[ti];
ti ;
if (ti >= servers.length) {
ti = 0;
}
return t;
}
// 生成監聽 error 事件函數,出錯時響應 500
function bindError(req, res, id) {
return function(err) {
const msg = String(err.stack || err);
log("[%s] 發生錯誤: %s", id, msg);
if (!res.headersSent) {
res.writeHead(500, { "content-type": "text/plain" });
}
res.end(msg);
};
}
return function proxy(req, res) {
// 生成代理請求信息
const target = getTarget();
const info = {
…target,
method: req.method,
path: req.url,
headers: req.headers
};
const id = `${req.method} ${req.url} => ${target.hostname}:${target.port}`;
log("[%s] 代理請求", id);
// 發送代理請求
const req2 = http.request(info, res2 => {
res2.on("error", bindError(req, res, id));
log("[%s] 響應: %s", id, res2.statusCode);
res.writeHead(res2.statusCode, res2.headers);
res2.pipe(res);
});
req.pipe(req2);
req2.on("error", bindError(req, res, id));
};
};
文件 log.js :
const util = require("util");
/** 打印日志 */
module.exports = function log(…args) {
const time = new Date().toLocaleString();
console.log(time, util.format(…args));
};
說明:
log.js 文件實現了一個用于打印日志的函數 log() ,它可以支持 console.log() 一樣的用法,并且自動在輸出前面加上當前的日期和時間,方便我們瀏覽日志
reverseProxy() 函數入口使用 assert 模塊來進行基本的參數檢查,如果參數格式不符合要求即拋出異常,保證可以第一時間讓開發者知道,而不是在運行期間發生各種 不可預測 的錯誤
getTarget() 函數用于循環返回一個目標服務器地址
bindError() 函數用于監聽 error 事件,避免整個程序因為沒有捕捉網絡異常而崩潰,同時可以統一返回出錯信息給客戶端
為了測試我們的代碼運行的效果,我編寫了一個簡單的程序,文件 server.js :
const http = require("http");
const log = require("./log");
const reverseProxy = require("./proxy");
// 創建反向代理服務器
function startProxyServer(port) {
return new Promise((resolve, reject) => {
const server = http.createServer(
reverseProxy({
servers: ["127.0.0.1:3001", "127.0.0.1:3002", "127.0.0.1:3003"]
})
);
server.listen(port, () => {
log("反向代理服務器已啟動: %s", port);
resolve(server);
});
server.on("error", reject);
});
}
// 創建演示服務器
function startExampleServer(port) {
return new Promise((resolve, reject) => {
const server = http.createServer(function(req, res) {
const chunks = [];
req.on("data", chunk => chunks.push(chunk));
req.on("end", () => {
const buf = Buffer.concat(chunks);
res.end(`${port}: ${req.method} ${req.url} ${buf.toString()}`.trim());
});
});
server.listen(port, () => {
log("服務器已啟動: %s", port);
resolve(server);
});
server.on("error", reject);
});
}
(async function() {
await startExampleServer(3001);
await startExampleServer(3002);
await startExampleServer(3003);
await startProxyServer(3000);
})();
執行以下命令啟動:
node server.js
然后可以通過 curl 命令來查看返回的結果:
curl http://127.0.0.1:3000/hello/world
連續執行多次該命令,如無意外輸出結果應該是這樣的(輸出內容端口部分按照順序循環):
3001: GET /hello/world
3002: GET /hello/world
3003: GET /hello/world
3001: GET /hello/world
3002: GET /hello/world
3003: GET /hello/world
注意:如果使用瀏覽器來打開該網址,看到的結果順序可能是不一樣的,因為瀏覽器會自動嘗試請求 /favicon ,這樣刷新一次頁面實際上是發送了兩次請求。
單元測試
上文我們已經完成了一個基本的 HTTP 反向代理程序,也通過簡單的方法驗證了它是能正常工作的。但是,我們并沒有足夠的測試,比如只驗證了 GET 請求,并沒有驗證 POST 請求或者其他的請求方法。而且通過手工去做更多的測試也比較麻煩,很容易遺漏。所以,接下來我們要給它加上自動化的單元測試。
在本文中我們選用在 Node.js 界應用廣泛的 mocha 作為單元測試框架,搭配使用 supertest 來進行 HTTP 接口請求的測試。由于 supertest 已經自帶了一些基本的斷言方法,我們暫時不需要chai 或者 should 這樣的第三方斷言庫。
首先執行 npm init 初始化一個 package.json 文件,并執行以下命令安裝 mocha 和 supertest :
npm install mocha supertest –save-dev
然后新建文件 test.js :
const http = require("http");
const log = require("./log");
const reverseProxy = require("./proxy");
const { expect } = require("chai");
const request = require("supertest");
// 創建反向代理服務器
function startProxyServer() {
return new Promise((resolve, reject) => {
const server = http.createServer(
reverseProxy({
servers: ["127.0.0.1:3001", "127.0.0.1:3002", "127.0.0.1:3003"]
})
);
log("反向代理服務器已啟動");
resolve(server);
});
}
// 創建演示服務器
function startExampleServer(port) {
return new Promise((resolve, reject) => {
const server = http.createServer(function(req, res) {
const chunks = [];
req.on("data", chunk => chunks.push(chunk));
req.on("end", () => {
const buf = Buffer.concat(chunks);
res.end(`${port}: ${req.method} ${req.url} ${buf.toString()}`.trim());
});
});
server.listen(port, () => {
log("服務器已啟動: %s", port);
resolve(server);
});
server.on("error", reject);
});
}
describe("測試反向代理", function() {
let server;
let exampleServers = [];
// 測試開始前先啟動服務器
before(async function() {
exampleServers.push(await startExampleServer(3001));
exampleServers.push(await startExampleServer(3002));
exampleServers.push(await startExampleServer(3003));
server = await startProxyServer();
});
// 測試結束后關閉服務器
after(async function() {
for (const server of exampleServers) {
server.close();
}
});
it("順序循環返回目標地址", async function() {
await request(server)
.get("/hello")
.expect(200)
.expect(`3001: GET /hello`);
await request(server)
.get("/hello")
.expect(200)
.expect(`3002: GET /hello`);
await request(server)
.get("/hello")
.expect(200)
.expect(`3003: GET /hello`);
await request(server)
.get("/hello")
.expect(200)
.expect(`3001: GET /hello`);
});
it("支持 POST 請求", async function() {
await request(server)
.post("/xyz")
.send({
a: 123,
b: 456
})
.expect(200)
.expect(`3002: POST /xyz {"a":123,"b":456}`);
});
});
說明:
在單元測試開始前,需要通過 before() 來注冊回調函數,以便在開始執行測試用例時先把服務器啟動起來
同理,通過 after() 注冊回調函數,以便在執行完所有測試用例后把服務器關閉以釋放資源(否則 mocha 進程不會退出)
使用 supertest 發送請求時,代理服務器不需要監聽端口,只需要將 server 實例作為調用參數即可
接著修改 package.json 文件的 scripts 部分:
{
"scripts": {
"test": "mocha test.js"
}
}
執行以下命令開始測試:
npm test
如果一切正常,我們應該會看到這樣的輸出結果,其中 passing 這樣的提示表示我們的測試完全通過了:
測試反向代理
2017-12-12 18:28:15 服務器已啟動: 3001
2017-12-12 18:28:15 服務器已啟動: 3002
2017-12-12 18:28:15 服務器已啟動: 3003
2017-12-12 18:28:15 反向代理服務器已啟動
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 代理請求
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 響應: 200
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3002] 代理請求
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3002] 響應: 200
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3003] 代理請求
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3003] 響應: 200
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 代理請求
2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 響應: 200
? 順序循環返回目標地址
2017-12-12 18:28:15 [POST /xyz => 127.0.0.1:3002] 代理請求
2017-12-12 18:28:15 [POST /xyz => 127.0.0.1:3002] 響應: 200
? 支持 POST 請求
2 passing (45ms)
當然以上的測試代碼還遠遠不夠,剩下的就交給讀者們來實現了。
接口改進
如果要設計成一個比較通用的反向代理中間件,我們還可以通過提供一個生成 http.ClientRequest 的函數來實現在代理時動態修改請求:
reverseProxy({
servers: ["127.0.0.1:3001", "127.0.0.1:3002", "127.0.0.1:3003"],
request: function(req, info) {
// info 是默認生成的 request options 對象
// 我們可以動態增加請求頭,比如當前請求時間戳
info.headers["X-Request-Timestamp"] = Date.now();
// 返回 http.ClientRequest 對象
return http.request(info);
}
});
然后在原來的 http.request(info, (res2) => {}) 部分可以改為監聽 response 事件:
const req2 = http.request(options.request(info));
req2.on("response", res2 => {});
同理,我們也可以通過提供一個函數來修改部分的響應內容:
reverseProxy({
servers: ["127.0.0.1:3001", "127.0.0.1:3002", "127.0.0.1:3003"],
response: function(res, info) {
// info 是發送代理請求時所用的 request options 對象
// 我們可以動態設置一些響應頭,比如實際代理的模板服務器地址
res.setHeader("X-Backend-Server", `${info.hostname}:${info.port}`);
}
});
此處只發散一下思路,具體實現方法和代碼就不再贅述了。
總結
本文主要介紹了如何使用內置的 http 模塊來創建一個 HTTP 服務器,以及發起一個 HTTP 請求,并簡單介紹了如何對 HTTP 接口進行測試。在實現 HTTP 請求代理的過程中,主要是運用了Stream 對象的 pipe() 方法,關鍵部分代碼只有區區幾行。Node.js 中的很多程序都運用了 Stream 這樣的思想,將數據當做一個流,使用 pipe 將一個流轉換成另一個流,可以看出 Stream在 Node.js 的重要性。
更多關于云服務器,域名注冊,虛擬主機的問題,請訪問三五互聯官網:m.shinetop.cn