当前位置: 首页 > 科技观察

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

时间:2023-03-15 15:33:56 科技观察

如果你经常使用Node.js编写web服务器程序,那么你一定对使用Nginx作为反向代理服务并不陌生。在生产环境中,我们经常需要将程序部署到内网的多台服务器上。在多核服务器上,为了充分利用所有的CPU资源,我们还需要启动多个服务进程,分别监听不同的端口。然后使用Nginx作为反向代理服务器,接收用户浏览器的请求,转发给多个后端web服务器。大致的工作流程如下:在Node.js上实现一个简单的HTTP代理程序非常简单。本文示例的核心代码只有60多行,只要了解内置http模块的基本用法,请看下文。接口设计及相关技术http.createServer()创建的HTTP服务器一般有function(req,res){}(以下简称requestHandler)处理请求的函数格式,接收两个参数,分别是http.IncomingMessage和http.ServerResponse对象,我们可以通过这两个对象来获取请求的所有信息并进行响应。主流的Node.jsweb框架的中间件(比如connect)一般有两种形式:中间件不需要任何初始化参数,它的导出结果是一个requestHandler。中间件需要初始化参数,它的导出结果是中间件的初始化函数,在执行初始化函数时,传入一个options对象,执行后返回一个requestHandler。为了让代码更加规范,在本文的例子中,我们将反向代理程序设计为中间件格式,并使用上面第二种类型的接口://生成中间件consthandler=reverseProxy({//初始化参数,用于设置目标服务器listservers:["127.0.0.1:3001","127.0.0.1:3002","127.0.0.1:3003"]});//可以直接使用constserver=http.createServer(handler)在http模块中;//在connect模块中使用app.use(handler)作为中间件;注意:上面代码中,reverseProxy是反向代理服务器中间件的初始化函数,它接受一个对象参数,servers是一个后端服务器地址列表,每个地址的格式为IP地址:端口,执行后reverseProxy(),像这样返回一个函数(req,res){}函数用来处理HTTP请求。可以作为connect中间件的http.createServer()和app.use()的处理函数。向该地址的服务器请求代理服务器收到HTTP请求后,首先需要向要代理的目标服务器发起新的HTTP请求。您可以使用http.request()发送请求:constreq=http.request({hostname:"targetserveraddress",port:"80",path:"requestpath",headers:{"x-y-z":"requestheader"}},function(res){//res为响应对象console.log(res.statusCode);});//如果有请求体发送,使用write()和end()req.end();客户端的请求体(Body部分,在POST和PUT请求中会有请求体)到另一个服务器,可以使用Stream对象的pipe()方法,例如:=//req和res是请求和客户端的响应对象//req2和res2是服务端发起的代理请求和响应对象//将req收到的数据转发给req2req.pipe(req2);//将res2收到的数据转发给resres2。管道(资源);说明:req对象是一个ReadableStream(可读流),通过data事件Data接收,当接收到end事件时,表示数据接收完毕。res对象是一个WritableStream(可写流)。数据通过write()方法输出,end()方法用于结束输出。为了简化从ReadableStream中获取数据事件,使用WritableStream的write()方法输出数据,可以使用ReadableStream的pipe()方法。以上只是提到了实现HTTP代理所需要的关键技术。相关接口的详细文档可以参考这里:https://nodejs.org/api/http.html#http_http_request_options_callback当然,为了实现一个界面友好的程序,往往需要做很多额外的工作,请参阅下面的详细信息以下是实现一个简单的HTTP反向代理服务器(没有任何第三方库依赖)的各种文件和代码的简单版本。为了让代码更简洁,使用了一些最新的ES语法特性,需要使用最新版本的Nodev8.x来运行:文件proxy.js:consthttp=require("http");constassert=require("assert");constlog=require("./log");/**反向代理中间件*/module.exports=functionreverseProxy(options){assert(Array.isArray(options.servers),"options.serversmustbeanarray");assert(options.servers.length>0,"options.servers的长度必须大于0");//解析服务器地址,生成hostname和portconstservers=options.servers.map(str=>{consts=str.split(":");return{hostname:s[0],port:s[1]||"80"};});//获取后端服务器,顺序循环letti=0;functiongetTarget(){constt=servers[ti];ti++;if(ti>=servers.length){ti=0;}returnt;}//生成监听错误事件的函数,响应500functionbindError(req,res,id){returnfunction(err){constmsg=String(err.stack||err)发生错误时;log("[%s]发生错误:%s",id,msg);if(!res.headersSent){res.writeHead(500,{"content-type":"text/plain"});}res.end(msg);};}returnfunctionproxy(req,res){//生成代理请求信息url}=>${target.hostname}:${target.port}`;log("[%s]proxyrequest",id);//发送代理请求constreq2=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:constutil=require("util");/**打印日志*/module.exports=functionlog(...args){consttime=newDate().toLocaleString();console.log(time,util格式(...参数));};注:log.js文件实现了一个打印日志的函数log(),可以支持和console.log()一样的用法,自动在输出前加上当前日期和时间,方便我们浏览日志reverseProxy()函数入口使用断言模块执行基本参数检查。如果参数格式不符合要求,会抛出异常,保证第一时间通知开发者,而不是在运行时出现各种不可预知的错误getTarget()函数循环返回一个目标服务器地址.bindError()函数用于监听错误事件,避免因未能捕捉到网络异常导致整个程序崩溃。同时可以统一向客户端返回错误信息。为了测试我们代码运行的效果,我写了一个简单的程序,文件server.js:consthttp=require("http");constlog=require("./log");constreverseProxy=require("./proxy");//创建反向代理服务器函数startProxyServer(port){returnnewPromise((resolve,reject)=>{constserver=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);});}//创建一个演示服务器chunks.push(chunk));req.on("end",()=>{constbuf=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);});}(asyncfunction(){awaitstartExampleServer(3001);awaitstartExampleServer(3002);awaitstartExampleServer(3003);awaitstartProxyServer(3000);})();执行以下命令启动:nodeserver.js然后使用curl命令查看返回结果:curlhttp://127.0。0.1:3000/hello/world连续多次执行这条命令。如无意外,输出应该是这样的(输出内容的端口部分按顺序循环):??3001:GET/??hello/world3002:GET/??hello/world3003:GET/??hello/world3001:GET/??hello/world3002:GET/??hello/world3003:GET/??hello/world注意:如果用浏览器打开这个网址,看到的结果顺序可能不同,因为浏览器会自动尝试请求/favicon,刷新页面这样其实就是发送两个请求单元测试以上我们完成了一个基本的HTTP反向代理程序,并通过简单的方法验证了它可以正常工作。但是我们没有进行足够的测试,比如只验证GET请求,不验证POST请求或其他请求方式。而且,手动多做测试比较麻烦,容易遗漏。因此,接下来我们必须为其添加自动化单元测试。在本文中,我们选择在Node.js世界中被广泛使用的mocha作为单元测试框架,使用supertest来测试HTTP接口请求。由于supertest已经自带了一些基本的断言方法,我们暂时不需要像chai或should这样的第三方断言库。首先执行npminit初始化一个package.json文件,执行以下命令安装mocha和supertest:npminstallmochasupertest--save-dev然后新建文件test.js:consthttp=require("http");constlog=require("./log");constreverseProxy=require("./proxy");const{expect}=require("chai");constrequest=require("supertest");//创建反向代理服务器函数startProxyServer(){returnnewPromise((resolve,reject)=>{constserver=http.createServer(reverseProxy({servers:["127.0.0.1:3001","127.0.0.1:3002","127.0.0.1:3003"]}));log("reverseTheproxyserverhasstarted");resolve(server);});}//创建demo服务器函数startExampleServer(port){returnnewPromise((resolve,reject)=>{constserver=http.createServer(function(req,res){constchunks=[];req.on("data",chunk=>chunks.push(chunk));req.on("end",()=>{constbuf=Buffer.concat(块);res.end(`${port}:${req.method}${req.url}${buf.toString()}`.trim());});});server.listen(port,()=>{log("服务器启动:%s",port);resolve(server);});server.on("error",reject);});}describe("测试反向代理",function(){letserver;letexampleServers=[];//在测试开始之前启动服务器before(asyncfunction(){exampleServers.push(awaitstartExampleServer(3001));exampleServers.push(awaitstartExampleServer(3002));exampleServers.push(awaitstartExampleServer(3003));server=awaitstartProxyServer();});//测试结束后关闭服务器after(asyncfunction(){for(constserverofexampleServers){server.close();}});it("顺序循环返回目标地址",asyncfunction(){awaitrequest(server).get("/hello").expect(200).expect(`3001:GET/??hello`);awaitrequest(server).get("/hello").expect(200).expect(`3002:GET/hello`);awaitrequest(server).get("/hello").expect(200).expect(`3003:GET/??hello`);awaitrequest(server).get("/hello").expect(200).expect(`3001:GET/??hello`);});it("支持POST请求",asyncfunction(){awaitrequest(server).post("/xyz").send({a:123,b:456}).expect(200).expect(`3002:POST/xyz{"a":123,"b":456}`);});});注意:在单元测试开始之前,需要通过before()注册回调函数,这样才能在测试用例启动的时候启动服务端同理,通过after()注册回调函数,这样服务端会在所有测试用例执行完毕后关闭释放资源(否则mocha进程不会退出)。使用supertest发送请求时,代理服务器不需要监听端口,只需要设置服务器实例即可作为调用参数修改package.json文件的scripts部分:{"scripts":{"test":"mochatest.js"}}执行如下命令开始测试:npmtest如果一切正常,我们应该会看到这样的输出结果,这里提示通过就说明我们的测试已经完全通过了:Testreverseproxy2017-12-1218:28:15服务器已启动:30012017-12-1218:28:15服务器已启动:30022017-12-1218:28:15服务器已启动:30032017-12-1218:28:15反向代理服务器启动2017-12-1218:28:15[GET/hello=>127.0.0.1:3001]代理请求2017-12-1218:28:15[GET/hello=>127.0.0.1:3001]响应:2002017-12-1218:28:15[GET/hello=>127.0.0.1:3002]代理请求2017-12-1218:28:15[GET/hello=>127.0.0.1:3002]响应:2002017-12-1218:28:15[GET/hello=>127.0.0.1:3003]代理请求2017-12-1218:28:15[GET/hello=>127.0.0.1:3003]响应:2002017-12-1218:28:15[GET/hello=>127.0.0.1:3001]代理请求2017-12-1218:28:15[GET/hello=>127.0.0.1:3001]响应:200?顺序循环返回目标地址2017-12-1218:28:15[POST/xyz=>127.0.0.1:3002]代理请求2017-12-1218:28:15[POST/xyz=>127.0.0.1:3002]响应:200?支持POST请求2passing(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是默认生成的requestoptions对象//我们可以动态增加请求头,比如当前请求时间戳信息.headers["X-Request-Timestamp"]=Date.now();//返回http.ClientRequest对象returnhttp.request(info);}});然后在原来的http.request(info,(res2)=>{})部分可以改成监听response事件:constreq2=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是发送代理请求时使用的requestoptions对象//我们可以动态设置一些响应头,比如实际的代理模板服务器地址res.setHeader("X-Backend-Server",`${info.hostname}:${info.port}`);}});这里只是发散一下思路,具体的实现方法和代码这里不再赘述。总结本文主要介绍如何使用内置的http模块创建HTTP服务器,发起HTTP请求,并简单介绍如何测试HTTP接口。在实现HTTP请求代理的过程中,主要使用了Stream对象的pipe()方法,关键部分代码只有几行。Node.js中的很多程序都使用了Stream的思想,将数据看成一个流,使用pipe将一个流转换成另一个流。可见Stream在Node.js中的重要性。