实现代理服务, 最常见的便是代理服务器代理相应的协议体请求源站, 并将响应从源站转发给客户端. 而在本文的场景中, 代理服务及源服务采用相同技术栈(Node.JS), 源服务是由代理服务 fork 出的业务服务(如下图), 代理服务不仅负责请求反向代理及转发规则设定, 同时也负责业务服务伸缩扩容, 日志输出与相关资源监控报警. 下文称源服务为业务服务.
最初笔者采用上图的架构, 业务服务为真正的 HTTP 服务或 webSocket 服务, 其侦听服务器的某个端口并处理代理服务的转发请求. 可这有一些问题会困扰我们:
业务服务需要侦听端口, 而端口是有上限的且有可能冲突(尽管可以避免冲突)
代理服务转发请求时, 又在内核走了一次 TCP/IP 协议栈解析, 且存在性能损耗(TCP 的慢启动, ack 机制等可靠性保证导致传输性能降低)
转发策略需要与端口耦合, 业务移植时存在风险
因此, 笔者尝试寻找更优的解决方案.
基于 Unix Socket 协议的 HTTP Server
老实说, 之前学习 Linux 网络编程的时候从没有尝试基于域套接字的 HTTP Server, 不过从协议上说, HTTP 协议并没有严格要求传输层协议必须为 TCP, 因此如果底层采用基于字节流的 Unix Socket 传输, 应该也是可以实现要求的.
同时相比较 TCP 协议实现的可靠传输, Unix Socket 作为 IPC 有些优点:
Unix Socket 仅仅复制数据, 并不执行协议处理, 不需要添加或删除网络报头, 无需计算校验和, 不产生顺序号, 也不需要发送确认报文
仅依赖命名管道, 不占用端口
Unix Socket 并不是一种协议, 它是进程间通信 (IPC) 的一种方式, 解决本机的两个进程通信
在 Node.JS 的 http 模块和 net 模块, 都提供了相关接口 "listen(path, cb)", 不同的是 http 模块在 Unix Socket 之上封装了 HTTP 的协议解析及相关规范, 因此这是可以无缝兼容基于 TCP 实现的 HTTP 服务的.
下为基于 Unix Socket 的 HTTP Server 与 Client 样例:
- const http = require('http');
- const path = require('path');
- const fs = require('fs');
- const p = path.join(__dirname,'tt.sock');
- fs.unlinkSync(p);
- let s = http.createServer((req, res)=> {
- req.setEncoding('utf8')
- req.on('data',(d)=>{
- console.log('server get:', d)
- });
- res.end('helloworld!!!');
- });
- s.listen(p);
- setTimeout(()=>{
- let c = http.request( {
- method: 'post',
- socketPath: p,
- path: '/test'
- }, (res) => {
- res.setEncoding('utf8');
- res.on('data', (chunk) => {
- console.log(` 响应主体: ${chunk}`);
- });
- res.on('end', () => {
- });
- });
- c.write(JSON.stringify({abc: '12312312312'}));
- c.end();
- },2000)
代理服务与业务服务进程的创建
代理服务不仅仅是代理请求, 同时也负责业务服务进程的创建. 在更为高级的需求下, 代理服务同时也担负业务服务进程的扩容与伸缩, 当业务流量上来时, 为了提高业务服务的吞吐量, 代理服务需要创建更多的业务服务进程, 流量洪峰消散后回收适当的进程资源. 透过这个角度会发现这种需求与 cluster 和 child_process 模块息息相关, 因此下文会介绍业务服务集群的具体实现.
本文中的代理为了实现具有粘性 session 功能的 WebSocket 服务, 因此采用了 child_process 模块创建业务进程. 这里的粘性 session 主要指的是 Socket.IO 的握手报文需要始终与固定的进程进行协商, 否则无法建立 Socket.IO 连接 (此处 Socket.IO 连接特指 Socket.IO 成功运行之上的连接), 具体可见我的文章 socket.io 搭配 pm2(cluster) 集群解决方案 . 不过, 在 fork 业务进程的时候, 会通过 pre_hook 脚本重写子进程的 http.Server.listen() 从而实现基于 Unix Socket 的底层可靠传输, 这种方式则是参考了 cluster 模块对子进程的相关处理, 关于 cluster 模块覆写子进程的 listen, 可参考我的另一篇文章 Node.JS cluster 模块深入探究 的 "多个子进程与端口复用" 一节.
- // 子进程 pre_hook 脚本, 实现基于 Unix Socket 可靠传输的 HTTP Server
- function setupEnvironment() {
- process.title = 'ProxyNodeApp:' + process['env']['APPNAME'];
- http.Server.prototype.originalListen = http.Server.prototype.listen;
- http.Server.prototype.listen = installServer;
- loadApplication();
- }
- function installServer() {
- var server = this;
- var listenTries = 0;
- doListen(server, listenTries, extractCallback(arguments));
- return server;
- }
- function doListen(server, listenTries, callback) {
- function errorHandler(error) {
- // error handle
- }
- // 生成 pipe
- var socketPath = domainPath = generateServerSocketPath();
- server.once('error', errorHandler);
- server.originalListen(socketPath, function() {
- server.removeListener('error', errorHandler);
- doneListening(server, callback);
- process.nextTick(finalizeStartup);
- });
- process.send({
- type: 'path',
- path: socketPath
- });
- }
这样就完成了业务服务的底层基础设施, 到了业务服务的编码阶段无需关注传输层的具体实现, 仍然使用 http.Server.listen(${any_port})即可. 此时业务服务侦听任何端口都可以, 因为在传输层根本没有使用该端口, 这样就避免了系统端口的浪费.
流量转发
流量转发包括了 HTTP 请求和 WebSocket 握手报文, 虽然 WebSocket 握手报文仍然是基于 HTTP 协议实现, 但需要不同的处理, 因此这里分开来说.
HTTP 流量转发
此节可参考 "基于 Unix Socket 的 HTTP Server 与 Client" 的示例, 在代理服务中新创建基于 Unix Socket 的 HTTP client 请求业务服务, 同时将响应 pipe 给客户端.
- class Client extends EventEmitter{
- constructor(options) {
- super();
- options = options || {};
- this.originHttpSocket = options.originHttpSocket;
- this.res = options.res;
- this.rej = options.rej;
- if (options.socket) {
- this.socket = options.socket;
- } else {
- let self = this;
- this.socket = http.request({
- method: self.originHttpSocket.method,
- socketPath: options.sockPath,
- path: self.originHttpSocket.url,
- headers: self.originHttpSocket.headers
- }, (res) => {
- self.originHttpSocket.set(res.headers);
- self.originHttpSocket.res.writeHead(res.statusCode);
- // 代理响应
- res.pipe(self.originHttpSocket.res)
- self.res();
- });
- }
- }
- send() {
- // 代理请求
- this.originHttpSocket.req.pipe(this.socket);
- }
- }
- // proxy server
- const App = new koa();
- App.use(async ctx => {
- await new Promise((res,rej) => {
- // 代理请求
- let client = new Client({
- originHttpSocket: ctx,
- sockPath: domainPath,
- res,
- rej
- });
- client.send();
- });
- });
- let server = App.listen(8000);
WebSocket 报文处理
如果不做 WebSocket 报文处理, 到此为止采用 Socket.IO 仅仅可以使用 "polling" 模式, 即通过 XHR 轮询的形式实现假的长连接, WebSocket 连接无法建立. 因此, 如果为了更好性能体验, 需要处理 WebSocket 报文. 这里主要参考了 "http-proxy" 的实现, 针对报文做了一些操作:
头部协议升级字段检查
基于 Unix Socket 的协议升级代理请求
报文处理的核心在于第 2 点: 创建一个代理服务与业务服务进程之间的 "长连接"(该连接时基于 Unix Socket 管道的, 而非 TCP 长连接), 并使用此连接 overlay 的 HTTP 升级请求进行协议升级.
此处实现较为复杂, 因此只呈现代理服务的处理, 关于 WebSocket 报文处理的详细过程, 可参考 .
- // 初始化 ws 模块
- wsHandler = new WsHandler({
- target: {
- socketPath: domainPath
- }
- }, (err, req, socket) => {
- console.error(` 代理 wsHandler 出错 `, err);
- });
- // 代理 ws 协议握手升级
- server.on('upgrade',(req, socket, head) =>{
- wsHandler.ws(req, socket, head);
- });
回顾与总结
大家都知道, 在 Node.JS 范畴实现 HTTP 服务集群, 应该使用 cluster 模块而不是 "child_process" 模块, 这是因为采用 child_process 实现的 HTTP 服务集群会出现调度上不均匀的问题(内核为了节省上下文切换开销做出来的 "优化之举", 详情可参考 Node.JS cluster 模块深入探究 "请求分发策略" 一节). 可为何在本文的实现中仍采用 child_process 模块呢?
答案是: 场景不同. 作为代理服务, 它可以使用 cluster 模块实现代理服务的集群; 而针对业务服务, 在 session 的场景中需要由代理服实现对应的转发策略, 其他情况则采用 RoundRobin 策略即可, 因此 child_process 模块更为合适.
本文并未实现代理服务的负载均衡策略, 其实现仍然在 Node.JS cluster 模块深入探究 中讲述, 因此可参阅此文.
最终, 在保持进程模型稳定的前提下, 变更了底层协议可实现更高性能的代理服务.
本文代码.
来源: https://www.cnblogs.com/accordion/p/12457505.html