如果你经常使用 Node.js 编写 Web 服务端程序,一定对使用 Nginx 作为 反向代理 服务并不陌生。在生产环境中,我们往往需要将程序部署到内网多台服务器上,在一台多核服务器上,为了充分利用所有 CPU 资源,也需要启动多个服务进程,它们分别监听不同的端口。然后使用 Nginx 作为反向代理服务器,接收来自用户浏览器的请求并转发到后端的多台 Web 服务器上。大概工作流程如下图:
在 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);
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
说明:
上面的代码中,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();
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
要将客户端的请求体(Body 部分,在 POST、PUT 这些请求时会有请求体)转发到另一个服务器上,可以使用 Stream 对象的 pipe() 方法,比如:=
// req 和 res 为客户端的请求和响应对象
// req2 和 res2 为服务器发起的代理请求和响应对象
// 将 req 收到的数据转发到 req2
req.pipe(req2);
// 将 res2 收到的数据转发到 res
res2.pipe(res);
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
说明:
- 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));
};
};
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
文件 log.js:
const util = require("util");
/** 打印日志 */
module.exports = function log(...args) {
const time = new Date().toLocaleString();
console.log(time, util.format(...args));
};
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
说明:
- 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);
})();
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
执行以下命令启动:
node server.js
- 1.
然后可以通过 curl 命令来查看返回的结果:
curl http://127.0.0.1:3000/hello/world
- 1.
连续执行多次该命令,如无意外输出结果应该是这样的(输出内容端口部分按照顺序循环):
3001: GET /hello/world
3002: GET /hello/world
3003: GET /hello/world
3001: GET /hello/world
3002: GET /hello/world
3003: GET /hello/world
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
注意:如果使用浏览器来打开该网址,看到的结果顺序可能是不一样的,因为浏览器会自动尝试请求/favicon,这样刷新一次页面实际上是发送了两次请求。
单元测试
上文我们已经完成了一个基本的 HTTP 反向代理程序,也通过简单的方法验证了它是能正常工作的。但是,我们并没有足够的测试,比如只验证了 GET 请求,并没有验证 POST 请求或者其他的请求方法。而且通过手工去做更多的测试也比较麻烦,很容易遗漏。所以,接下来我们要给它加上自动化的单元测试。
在本文中我们选用在 Node.js 界应用广泛的 mocha 作为单元测试框架,搭配使用 supertest 来进行 HTTP 接口请求的测试。由于 supertest 已经自带了一些基本的断言方法,我们暂时不需要 chai 或者 should 这样的第三方断言库。
首先执行 npm init 初始化一个 package.json 文件,并执行以下命令安装 mocha 和 supertest:
npm install mocha supertest --save-dev
- 1.
然后新建文件 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}`);
});
});
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
说明:
在单元测试开始前,需要通过 before() 来注册回调函数,以便在开始执行测试用例时先把服务器启动起来
同理,通过 after() 注册回调函数,以便在执行完所有测试用例后把服务器关闭以释放资源(否则 mocha 进程不会退出)
使用 supertest 发送请求时,代理服务器不需要监听端口,只需要将 server 实例作为调用参数即可
接着修改 package.json 文件的 scripts 部分:
{
"scripts": {
"test": "mocha test.js"
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
执行以下命令开始测试:
npm test
- 1.
如果一切正常,我们应该会看到这样的输出结果,其中 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)
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
当然以上的测试代码还远远不够,剩下的就交给读者们来实现了。
接口改进
如果要设计成一个比较通用的反向代理中间件,我们还可以通过提供一个生成 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);
}
});
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
然后在原来的 http.request(info, (res2) => {}) 部分可以改为监听 response 事件:
const req2 = http.request(options.request(info));
req2.on("response", res2 => {});
- 1.
- 2.
同理,我们也可以通过提供一个函数来修改部分的响应内容:
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}`);
}
});
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
此处只发散一下思路,具体实现方法和代码就不再赘述了。
总结
本文主要介绍了如何使用内置的 http 模块来创建一个 HTTP 服务器,以及发起一个 HTTP 请求,并简单介绍了如何对 HTTP 接口进行测试。在实现 HTTP 请求代理的过程中,主要是运用了 Stream 对象的 pipe() 方法,关键部分代码只有区区几行。Node.js 中的很多程序都运用了 Stream 这样的思想,将数据当做一个流,使用 pipe 将一个流转换成另一个流,可以看出 Stream 在 Node.js 的重要性。