基於Unix Socket的可靠Node.js HTTP代理實現(支援WebSocket協議)
阿新 • • 發佈:2020-03-10
實現代理服務,最常見的便是代理伺服器代理相應的協議體請求源站,並將響應從源站轉發給客戶端。而在本文的場景中,代理服務及源服務採用相同技術棧(Node.js),源服務是由代理服務fork出的業務服務(如下圖),代理服務不僅負責請求反向代理及轉發規則設定,同時也負責業務服務伸縮擴容、日誌輸出與相關資源監控報警。下文稱源服務為**業務服務**。
![![enter image description here](https://si.geilicdn.com/vms-0d5900000170bd57c5c80a21924b-unadjust_858_630.png)](https://si.geilicdn.com/vms-0d5900000170bd57c5c80a21924b-unadjust_858_630.png)
最初筆者採用上圖的架構,業務服務為真正的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)叢集解決方案](https://www.cnblogs.com/accordion/p/6930152.html) 。不過,在fork業務程序的時候,會通過pre_hook指令碼重寫子程序的 **http.Server.listen()** 從而實現基於Unix Socket的底層可靠傳輸,這種方式則是參考了 cluster 模組對子程序的相關處理,關於cluster模組覆寫子程序的listen,可參考我的另一篇文章 [Nodejs cluster模組深入探究](https://www.cnblogs.com/accordion/p/7207740.html) 的“多個子程序與埠複用”一節。
```
// 子程序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”的實現,針對報文做了一些操作:
1. 頭部協議升級欄位檢查
2. 基於Unix Socket的協議升級代理請求
報文處理的核心在於第2點:建立一個代理服務與業務服務程序之間的“長連線”(該連線時基於Unix Socket管道的,而非TCP長連線),並使用此連線overlay的HTTP升級請求進行協議升級。
此處實現較為複雜,因此只呈現代理服務的處理,關於WebSocket報文處理的詳細過程,可參考 [proxy-based-unixsocket](https://github.com/royalrover/proxy-based-unixsocket)。
```
// 初始化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服務叢集會出現排程上不均勻的問題(核心為了節省上下文切換開銷做出來的“優化之舉”,詳情可參考 [Nodejs cluster模組深入探究](https://www.cnblogs.com/accordion/p/7207740.html)“請求分發策略”一節)。可為何在本文的實現中仍採用child_process模組呢?
答案是:場景不同。作為代理服務,它可以使用cluster模組實現代理服務的叢集;而針對業務服務,在session的場景中需要由代理服實現對應的轉發策略,其他情況則採用RoundRobin策略即可,因此child_process模組更為合適。
本文並未實現代理服務的負載均衡策略,其實現仍然在 [Nodejs cluster模組深入探究](https://www.cnblogs.com/accordion/p/7207740.html) 中講述,因此可參閱此文。
最終,在保持程序模型穩定的前提下,變更了底層協議可實現更高效能的代理服務。
本文程式碼[proxy-based-unixsocket](https://github.com/royalrover/proxy-based-unixsocket)。
![![enter image description here](https://si.geilicdn.com/vms-448a00000170c3df9ed40a2262e0-unadjust_910_612.png)](https://si.geilicdn.com/vms-448a00000170c3df9ed40a2262e0-unadjust_910_