您的位置:首页 >资讯列表 > 正文
发布时间:2020-04-17 17:09
NginxHTTP代理服务器的设计与实现

  如果你经常使用Node.js编写Web服务端程序,一定对使用Nginx作为反向http代理服务并不陌生。在生产环境中,我们往往需要将程序部署到内网多台服务器上,在一台多核服务器上,为了充分利用所有CPU资源,也需要启动多个服务进程,它们分别监听不同的端口。然后使用Nginx作为反向代理服务器,接收来自用户浏览器的请求并转发到后端的多台Web服务器上。大概工作流程如下图:


u=3344895041,3546629381&fm=26&gp=0.jpg


  在Node.js上实现一个简单的HTTP代理程序还是非常简单的,本文章的例子的核心代码只有60多行,只要理解内置http模块的基本用法即可,具体请看下文。


  接口设计与相关技术


  使用创建的HTTP服务器,处理请求的函数格式一般为(下文简称为),其接收两个参数,分别为http.IncomingMessage和http.ServerResponse对象,我们可以通过这两个对象来取得请求的所有信息并对它进行响应。主流的Node.js Web框架的中间件(比如connect)一般都有两种形式:中间件不需要任何初始化参数,则其导出结果为一个中间件需要初始化参数,则其导出结果为中间件的初始化函数,执行该初始化函数时,传入一个对象,执行后返回一个为了使代码更规范,在本文例子中,我们将反向代理程序设计成一个中间件的格式,并使用以上第二种接口形式:


  //生成中间件


  consthandler=reverseProxy({


  //初始化参数,用于设置目标服务器列表


  servers:["127.0.0.1:3001","127.0.0.1:3002","127.0.0.1:3003"]


  });


  //可以直接在http模块中使用


  constserver=http.createServer(handler);


  //作为中间件在connect模块中使用


  app.use(handler);


  说明:上面的代码中,是反向代理服务器中间件的初始化函数,它接受一个对象参数,是后端服务器地址列表,每个地址为这样的格式执行后返回一个这样的函数,用于处理HTTP请求,可作为和connect中间件的的处理函数当接收到客户端请求时,按顺序循环从数组中取出一个服务器地址,将请求代理到这个地址的服务器上服务器在接收到HTTP请求后,首先需要发起一个新的HTTP请求到要代理的目标服务器,可以使用来发送请求:


  constreq=http.request(


  {


  hostname:"目标服务器地址",


  port:"80",


  path:"请求路径",


  headers:{


  "x-y-z":"请求头"


  }


  },


  function(res){


  //res为响应对象


  console.log(res.statusCode);


  }


  );


  //如果有请求体需要发送,使用write()和end()


  req.end();


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


  //req和res为客户端的请求和响应对象


  //req2和res2为服务器发起的代理请求和响应对象


  //将req收到的数据转发到req2


  req.pipe(req2);


  //将res2收到的数据转发到res


  res2.pipe(res);


  说明:对象是一个Readable Stream(可读流),通过事件来接收数据,当收到事件时表示数据接收完毕对象是一个Writable Stream(可写流),通过方法来输出数据,方法来结束输出为了简化从监听事件来获取数据并使用的方法来输出,可以使用的方法以上只是提到了实现HTTP代理需要的关键技术,相关接口的详细文档可以参考这里:https://nodejs.org/api/http.html#http_http_request_options_callback当然为了实现一个接口友好的程序,往往还需要很多额外的工作,具体请看下文。


  简单版本


  以下是实现一个简单HTTP反向代理服务器的各个文件和代码(没有任何第三方库依赖),为了使代码更简洁,使用了一些最新的ES语法特性,需要使用Node v8.x最新版本来运行:文件:


  consthttp=require("http");


  constassert=require("assert");


  constlog=require("./log");


  /**反向代理中间件*/


  module.exports=functionreverseProxy(options){


  assert(Array.isArray(options.servers),"options.servers必须是数组");


  assert(options.servers.length>,"options.servers的长度必须大于0");


  //解析服务器地址,生成hostname和port


  constservers=options.servers.map(str=>{


  consts=str.split(":");


  return{hostname:s[],port:s[1]"80"};


  });


  //获取一个后端服务器,顺序循环


  letti=;


  functiongetTarget(){


  constt=servers[ti];


  ti++;


  if(ti>=servers.length){


  ti=;


  }


  returnt;


  }


  //生成监听error事件函数,出错时响应500


  functionbindError(req,res,id){


  returnfunction(err){


  constmsg=String(err.stackerr);


  log("[%s]发生错误:%s",id,msg);


  if(!res.headersSent){


  res.writeHead(500,{"content-type":"text/plain"});


  }


  res.end(msg);


  };


  }


  returnfunctionproxy(req,res){


  //生成代理请求信息


  consttarget=getTarget();


  constinfo={


  ...target,


  method:req.method,


  path:req.url,


  headers:req.headers


  };


  constid=`${req.method}${req.url}=>${target.hostname}:${target.port}`;


  log("[%s]代理请求",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));


  };


  };


  文件:


  constutil=require("util");


  /**打印日志*/


  module.exports=functionlog(...args){


  consttime=newDate().toLocaleString();


  console.log(time,util.format(...args));


  };


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


  consthttp=require("http");


  constlog=require("./log");


  constreverseProxy=require("./proxy");


  //创建反向代理服务器


  functionstartProxyServer(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);


  });


  }


  //创建演示服务器


  functionstartExampleServer(port){


  returnnewPromise((resolve,reject)=>{


  constserver=http.createServer(function(req,res){


  constchunks=[];


  req.on("data",chunk=>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


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


  curlhttp://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


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


  单元测试


  上文我们已经完成了一个基本的HTTP反向代理程序,也通过简单的方法验证了它是能正常工作的。但是,我们并没有足够的测试,比如只验证了GET请求,并没有验证POST请求或者其他的请求方法。而且通过手工去做更多的测试也比较麻烦,很容易遗漏。所以,接下来我们要给它加上自动化的单元测试。在本文中我们选用在Node.js界应用广泛的mocha作为单元测试框架,搭配使用supertest来进行HTTP接口请求的测试。由于已经自带了一些基本的断言方法,我们暂时不需要chai或者should这样的第三方断言库。首先执行初始化一个文件,并执行以下命令安装和:


  npminstall mocha supertest--save-dev


  然后新建文件:


  consthttp=require("http");


  constlog=require("./log");


  constreverseProxy=require("./proxy");


  const{expect}=require("chai");


  constrequest=require("supertest");


  //创建反向代理服务器


  functionstartProxyServer(){


  returnnewPromise((resolve,reject)=>{


  constserver=http.createServer(


  reverseProxy({


  servers:["127.0.0.1:3001","127.0.0.1:3002","127.0.0.1:3003"]


  })


  );


  log("反向代理服务器已启动");


  resolve(server);


  });


  }


  //创建演示服务器


  functionstartExampleServer(port){


  returnnewPromise((resolve,reject)=>{


  constserver=http.createServer(function(req,res){


  constchunks=[];


  req.on("data",chunk=>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);


  });


  }


  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}`);


  });


  });


  说明:在单元测试开始前,需要通过来注册回调函数,以便在开始执行测试用例时先把服务器启动起来同理,通过注册回调函数,以便在执行完所有测试用例后把服务器关闭以释放资源(否则mocha进程不会退出)使用发送请求时,代理服务器不需要监听端口,只需要将实例作为调用参数即可接着修改文件的部分:


  {


  "scripts":{


  "test":"mocha test.js"


  }


  }


  执行以下命令开始测试:


  npmtest


  如果一切正常,我们应该会看到这样的输出结果,其中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对象


  returnhttp.request(info);


  }


  });


  然后在原来的部分可以改为监听事件:


  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是发送代理请求时所用的request options对象


  //我们可以动态设置一些响应头,比如实际代理的模板服务器地址


  res.setHeader("X-Backend-Server",`${info.hostname}:${info.port}`);


  }


  });


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


  总结


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


上一篇 管理一个Linux服务器HTTP代理 下一篇 HTTP实现代理服务器及缓存替换算法的研究