如何编写一个 HTTP 反向代理服务器

服务器
如果你经常使用 Node.js 编写 Web 服务端程序,一定对使用 Nginx 作为 反向代理 服务并不陌生。在生产环境中,我们往往需要将程序部署到内网多台服务器上,在一台多核服务器上,为了充分利用所有 CPU 资源,也需要启动多个服务进程,它们分别监听不同的端口。

如果你经常使用 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

为了使代码更规范,在本文例子中,我们将反向代理程序设计成一个中间件的格式,并使用以上第二种接口形式:

  1. // 生成中间件 
  2.   const handler = reverseProxy({ 
  3.     // 初始化参数,用于设置目标服务器列表 
  4.     servers: ["127.0.0.1:3001""127.0.0.1:3002""127.0.0.1:3003"
  5.   }); 
  6.    
  7.   // 可以直接在 http 模块中使用 
  8.   const server = http.createServer(handler); 
  9.    
  10.   // 作为中间件在 connect 模块中使用 
  11.   app.use(handler); 

说明:

上面的代码中,reverseProxy 是反向代理服务器中间件的初始化函数,它接受一个对象参数,servers 是后端服务器地址列表,每个地址为 IP地址:端口 这样的格式

执行 reverseProxy() 后返回一个 function (req, res) {} 这样的函数,用于处理 HTTP 请求,可作为 http.createServer() 和 connect 中间件的 app.use() 的处理函数

当接收到客户端请求时,按顺序循环从 servers 数组中取出一个服务器地址,将请求代理到这个地址的服务器上

服务器在接收到 HTTP 请求后,首先需要发起一个新的 HTTP 请求到要代理的目标服务器,可以使用 http.request() 来发送请求:

  1. const req = http.request( 
  2.     { 
  3.       hostname: "目标服务器地址"
  4.       port: "80"
  5.       path: "请求路径"
  6.       headers: { 
  7.         "x-y-z""请求头" 
  8.       } 
  9.     }, 
  10.     function(res) { 
  11.       // res 为响应对象 
  12.       console.log(res.statusCode); 
  13.     } 
  14.   ); 
  15.   // 如果有请求体需要发送,使用 write() 和 end() 
  16.   req.end(); 

要将客户端的请求体(Body 部分,在 POST、PUT 这些请求时会有请求体)转发到另一个服务器上,可以使用 Stream 对象的 pipe() 方法,比如:=

  1. // req 和 res 为客户端的请求和响应对象 
  2.   // req2 和 res2 为服务器发起的代理请求和响应对象 
  3.   // 将 req 收到的数据转发到 req2 
  4.   req.pipe(req2); 
  5.   // 将 res2 收到的数据转发到 res 
  6.   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:

  1. const http = require("http"); 
  2.   const assert = require("assert"); 
  3.   const log = require("./log"); 
  4.    
  5.   /** 反向代理中间件 */ 
  6.   module.exports = function reverseProxy(options) { 
  7.     assert(Array.isArray(options.servers), "options.servers 必须是数组"); 
  8.     assert(options.servers.length > 0, "options.servers 的长度必须大于 0"); 
  9.    
  10.     // 解析服务器地址,生成 hostname 和 port 
  11.     const servers = options.servers.map(str => { 
  12.       const s = str.split(":"); 
  13.       return { hostname: s[0], port: s[1] || "80" }; 
  14.     }); 
  15.    
  16.     // 获取一个后端服务器,顺序循环 
  17.     let ti = 0; 
  18.     function getTarget() { 
  19.       const t = servers[ti]; 
  20.       ti++; 
  21.       if (ti >= servers.length) { 
  22.         ti = 0; 
  23.       } 
  24.       return t; 
  25.     } 
  26.    
  27.     // 生成监听 error 事件函数,出错时响应 500 
  28.     function bindError(req, res, id) { 
  29.       return function(err) { 
  30.         const msg = String(err.stack || err); 
  31.         log("[%s] 发生错误: %s", id, msg); 
  32.         if (!res.headersSent) { 
  33.           res.writeHead(500, { "content-type""text/plain" }); 
  34.         } 
  35.         res.end(msg); 
  36.       }; 
  37.     } 
  38.    
  39.     return function proxy(req, res) { 
  40.       // 生成代理请求信息 
  41.       const target = getTarget(); 
  42.       const info = { 
  43.         ...target, 
  44.         method: req.method, 
  45.         path: req.url, 
  46.         headers: req.headers 
  47.       }; 
  48.    
  49.       const id = `${req.method} ${req.url} => ${target.hostname}:${target.port}`; 
  50.       log("[%s] 代理请求", id); 
  51.    
  52.       // 发送代理请求 
  53.       const req2 = http.request(info, res2 => { 
  54.         res2.on("error", bindError(req, res, id)); 
  55.         log("[%s] 响应: %s", id, res2.statusCode); 
  56.         res.writeHead(res2.statusCode, res2.headers); 
  57.         res2.pipe(res); 
  58.       }); 
  59.       req.pipe(req2); 
  60.       req2.on("error", bindError(req, res, id)); 
  61.     }; 
  62.   }; 

文件 log.js:

  1. const util = require("util"); 
  2.    
  3.   /** 打印日志 */ 
  4.   module.exports = function log(...args) { 
  5.     const time = new Date().toLocaleString(); 
  6.     console.log(time, util.format(...args)); 
  7.   }; 

说明:

  • log.js 文件实现了一个用于打印日志的函数 log(),它可以支持 console.log() 一样的用法,并且自动在输出前面加上当前的日期和时间,方便我们浏览日志
  • reverseProxy() 函数入口使用 assert 模块来进行基本的参数检查,如果参数格式不符合要求即抛出异常,保证可以第一时间让开发者知道,而不是在运行期间发生各种 不可预测 的错误
  • getTarget() 函数用于循环返回一个目标服务器地址
  • bindError() 函数用于监听 error 事件,避免整个程序因为没有捕捉网络异常而崩溃,同时可以统一返回出错信息给客户端

为了测试我们的代码运行的效果,我编写了一个简单的程序,文件 server.js:

  1. const http = require("http"); 
  2.   const log = require("./log"); 
  3.   const reverseProxy = require("./proxy"); 
  4.    
  5.   // 创建反向代理服务器 
  6.   function startProxyServer(port) { 
  7.     return new Promise((resolve, reject) => { 
  8.       const server = http.createServer( 
  9.         reverseProxy({ 
  10.           servers: ["127.0.0.1:3001""127.0.0.1:3002""127.0.0.1:3003"
  11.         }) 
  12.       ); 
  13.       server.listen(port, () => { 
  14.         log("反向代理服务器已启动: %s", port); 
  15.         resolve(server); 
  16.       }); 
  17.       server.on("error", reject); 
  18.     }); 
  19.   } 
  20.    
  21.   // 创建演示服务器 
  22.   function startExampleServer(port) { 
  23.     return new Promise((resolve, reject) => { 
  24.       const server = http.createServer(function(req, res) { 
  25.         const chunks = []; 
  26.         req.on("data", chunk => chunks.push(chunk)); 
  27.         req.on("end", () => { 
  28.           const buf = Buffer.concat(chunks); 
  29.           res.end(`${port}: ${req.method} ${req.url} ${buf.toString()}`.trim()); 
  30.         }); 
  31.       }); 
  32.       server.listen(port, () => { 
  33.         log("服务器已启动: %s", port); 
  34.         resolve(server); 
  35.       }); 
  36.       server.on("error", reject); 
  37.     }); 
  38.   } 
  39.    
  40.   (async function() { 
  41.     await startExampleServer(3001); 
  42.     await startExampleServer(3002); 
  43.     await startExampleServer(3003); 
  44.     await startProxyServer(3000); 
  45.   })(); 

执行以下命令启动:

  1. node server.js 

然后可以通过 curl 命令来查看返回的结果:

  1. curl http://127.0.0.1:3000/hello/world 

连续执行多次该命令,如无意外输出结果应该是这样的(输出内容端口部分按照顺序循环):

  1. 3001: GET /hello/world 
  2.   3002: GET /hello/world 
  3.   3003: GET /hello/world 
  4.   3001: GET /hello/world 
  5.   3002: GET /hello/world 
  6.   3003: GET /hello/world 

注意:如果使用浏览器来打开该网址,看到的结果顺序可能是不一样的,因为浏览器会自动尝试请求/favicon,这样刷新一次页面实际上是发送了两次请求。

单元测试

上文我们已经完成了一个基本的 HTTP 反向代理程序,也通过简单的方法验证了它是能正常工作的。但是,我们并没有足够的测试,比如只验证了 GET 请求,并没有验证 POST 请求或者其他的请求方法。而且通过手工去做更多的测试也比较麻烦,很容易遗漏。所以,接下来我们要给它加上自动化的单元测试。

在本文中我们选用在 Node.js 界应用广泛的 mocha 作为单元测试框架,搭配使用 supertest 来进行 HTTP 接口请求的测试。由于 supertest 已经自带了一些基本的断言方法,我们暂时不需要 chai 或者 should 这样的第三方断言库。

首先执行 npm init 初始化一个 package.json 文件,并执行以下命令安装 mocha 和 supertest:

  1. npm install mocha supertest --save-dev 

然后新建文件 test.js:

  1. const http = require("http"); 
  2.   const log = require("./log"); 
  3.   const reverseProxy = require("./proxy"); 
  4.   const { expect } = require("chai"); 
  5.   const request = require("supertest"); 
  6.    
  7.   // 创建反向代理服务器 
  8.   function startProxyServer() { 
  9.     return new Promise((resolve, reject) => { 
  10.       const server = http.createServer( 
  11.         reverseProxy({ 
  12.           servers: ["127.0.0.1:3001""127.0.0.1:3002""127.0.0.1:3003"
  13.         }) 
  14.       ); 
  15.       log("反向代理服务器已启动"); 
  16.       resolve(server); 
  17.     }); 
  18.   } 
  19.    
  20.   // 创建演示服务器 
  21.   function startExampleServer(port) { 
  22.     return new Promise((resolve, reject) => { 
  23.       const server = http.createServer(function(req, res) { 
  24.         const chunks = []; 
  25.         req.on("data", chunk => chunks.push(chunk)); 
  26.         req.on("end", () => { 
  27.           const buf = Buffer.concat(chunks); 
  28.           res.end(`${port}: ${req.method} ${req.url} ${buf.toString()}`.trim()); 
  29.         }); 
  30.       }); 
  31.       server.listen(port, () => { 
  32.         log("服务器已启动: %s", port); 
  33.         resolve(server); 
  34.       }); 
  35.       server.on("error", reject); 
  36.     }); 
  37.   } 
  38.    
  39.   describe("测试反向代理"function() { 
  40.     let server; 
  41.     let exampleServers = []; 
  42.    
  43.     // 测试开始前先启动服务器 
  44.     before(async function() { 
  45.       exampleServers.push(await startExampleServer(3001)); 
  46.       exampleServers.push(await startExampleServer(3002)); 
  47.       exampleServers.push(await startExampleServer(3003)); 
  48.       server = await startProxyServer(); 
  49.     }); 
  50.    
  51.     // 测试结束后关闭服务器 
  52.     after(async function() { 
  53.       for (const server of exampleServers) { 
  54.         server.close(); 
  55.       } 
  56.     }); 
  57.    
  58.     it("顺序循环返回目标地址", async function() { 
  59.       await request(server) 
  60.         .get("/hello"
  61.         .expect(200) 
  62.         .expect(`3001: GET /hello`); 
  63.    
  64.       await request(server) 
  65.         .get("/hello"
  66.         .expect(200) 
  67.         .expect(`3002: GET /hello`); 
  68.    
  69.       await request(server) 
  70.         .get("/hello"
  71.         .expect(200) 
  72.         .expect(`3003: GET /hello`); 
  73.    
  74.       await request(server) 
  75.         .get("/hello"
  76.         .expect(200) 
  77.         .expect(`3001: GET /hello`); 
  78.     }); 
  79.    
  80.     it("支持 POST 请求", async function() { 
  81.       await request(server) 
  82.         .post("/xyz"
  83.         .send({ 
  84.           a: 123, 
  85.           b: 456 
  86.         }) 
  87.         .expect(200) 
  88.         .expect(`3002: POST /xyz {"a":123,"b":456}`); 
  89.     }); 
  90.   }); 

说明:

在单元测试开始前,需要通过 before() 来注册回调函数,以便在开始执行测试用例时先把服务器启动起来

同理,通过 after() 注册回调函数,以便在执行完所有测试用例后把服务器关闭以释放资源(否则 mocha 进程不会退出)

使用 supertest 发送请求时,代理服务器不需要监听端口,只需要将 server 实例作为调用参数即可

接着修改 package.json 文件的 scripts 部分:

  1.    "scripts": { 
  2.      "test""mocha test.js" 
  3.    } 
  4.  } 

执行以下命令开始测试:

  1. npm test 

如果一切正常,我们应该会看到这样的输出结果,其中 passing 这样的提示表示我们的测试完全通过了:

  1. 测试反向代理 
  2.   2017-12-12 18:28:15 服务器已启动: 3001 
  3.   2017-12-12 18:28:15 服务器已启动: 3002 
  4.   2017-12-12 18:28:15 服务器已启动: 3003 
  5.   2017-12-12 18:28:15 反向代理服务器已启动 
  6.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 代理请求 
  7.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 响应: 200 
  8.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3002] 代理请求 
  9.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3002] 响应: 200 
  10.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3003] 代理请求 
  11.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3003] 响应: 200 
  12.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 代理请求 
  13.   2017-12-12 18:28:15 [GET /hello => 127.0.0.1:3001] 响应: 200 
  14.       ✓ 顺序循环返回目标地址 
  15.   2017-12-12 18:28:15 [POST /xyz => 127.0.0.1:3002] 代理请求 
  16.   2017-12-12 18:28:15 [POST /xyz => 127.0.0.1:3002] 响应: 200 
  17.       ✓ 支持 POST 请求 
  18.    
  19.    
  20.     2 passing (45ms) 

当然以上的测试代码还远远不够,剩下的就交给读者们来实现了。

接口改进

如果要设计成一个比较通用的反向代理中间件,我们还可以通过提供一个生成 http.ClientRequest 的函数来实现在代理时动态修改请求:

  1. reverseProxy({ 
  2.     servers: ["127.0.0.1:3001""127.0.0.1:3002""127.0.0.1:3003"], 
  3.     request: function(req, info) { 
  4.       // info 是默认生成的 request options 对象 
  5.       // 我们可以动态增加请求头,比如当前请求时间戳 
  6.       info.headers["X-Request-Timestamp"] = Date.now(); 
  7.       // 返回 http.ClientRequest 对象 
  8.       return http.request(info); 
  9.     } 
  10.   }); 

然后在原来的 http.request(info, (res2) => {}) 部分可以改为监听 response 事件:

  1. const req2 = http.request(options.request(info)); 
  2.  req2.on("response", res2 => {}); 

同理,我们也可以通过提供一个函数来修改部分的响应内容:

  1. reverseProxy({ 
  2.     servers: ["127.0.0.1:3001""127.0.0.1:3002""127.0.0.1:3003"], 
  3.     response: function(res, info) { 
  4.       // info 是发送代理请求时所用的 request options 对象 
  5.       // 我们可以动态设置一些响应头,比如实际代理的模板服务器地址 
  6.       res.setHeader("X-Backend-Server", `${info.hostname}:${info.port}`); 
  7.     } 
  8.   }); 

此处只发散一下思路,具体实现方法和代码就不再赘述了。

总结

本文主要介绍了如何使用内置的 http 模块来创建一个 HTTP 服务器,以及发起一个 HTTP 请求,并简单介绍了如何对 HTTP 接口进行测试。在实现 HTTP 请求代理的过程中,主要是运用了 Stream 对象的 pipe() 方法,关键部分代码只有区区几行。Node.js 中的很多程序都运用了 Stream 这样的思想,将数据当做一个流,使用 pipe 将一个流转换成另一个流,可以看出 Stream 在 Node.js 的重要性。

责任编辑:武晓燕 来源: 老雷的实验室
相关推荐

2024-01-08 08:36:29

HTTPGo代理服务器

2012-09-18 09:55:28

2019-04-08 08:39:47

Nginx代理服务器

2018-11-05 09:34:43

2009-02-10 15:42:00

代理服务器代理服务器设置

2018-04-17 12:10:40

2019-06-27 08:43:26

服务器Nginx反向代理

2018-03-01 10:45:25

HTTP服务器程序

2019-04-24 15:13:16

Web服务器应用服务器Web容器

2011-08-31 16:37:51

Nginx

2024-02-20 14:53:01

2009-12-07 09:33:38

2020-08-02 15:00:40

SquidSSH系统运维

2009-08-21 16:13:45

代理服务器设置迅雷

2019-10-22 10:20:13

Nginx反向代理服务器

2009-02-12 15:33:00

代理服务器HTTPSOCKS

2010-03-12 16:33:12

Python抓站

2009-02-06 11:12:00

代理服务器代理服务器应用

2017-09-20 10:22:15

Web服务器容器

2011-10-19 14:38:46

Node.js
点赞
收藏

51CTO技术栈公众号