基於Unix Socket的可靠Node.js HTTP代理實現(支持WebSocket協議)

實現代理服務,最多見的即是代理服務器代理相應的協議體請求源站,並將響應從源站轉發給客戶端。而在本文的場景中,代理服務及源服務採用相同技術棧(Node.js),源服務是由代理服務fork出的業務服務(以下圖),代理服務不只負責請求反向代理及轉發規則設定,同時也負責業務服務伸縮擴容、日誌輸出與相關資源監控報警。下文稱源服務爲業務服務
enter image description herehtml

最初筆者採用上圖的架構,業務服務爲真正的HTTP服務或WebSocket服務,其偵聽服務器的某個端口並處理代理服務的轉發請求。可這有一些問題會困擾咱們:linux

  • 業務服務須要偵聽端口,而端口是有上限的且有可能衝突(儘管能夠避免衝突)
  • 代理服務轉發請求時,又在內核走了一次TCP/IP協議棧解析,且存在性能損耗(TCP的慢啓動、ack機制等可靠性保證致使傳輸性能下降)
  • 轉發策略須要與端口耦合,業務移植時存在風險

所以,筆者嘗試尋找更優的解決方案。git

基於Unix Socket協議的HTTP Server

老實說,以前學習linux網絡編程的時候從沒有嘗試基於域套接字的HTTP Server,不過從協議上說,HTTP協議並無嚴格要求傳輸層協議必須爲TCP,所以若是底層採用基於字節流的Unix Socket傳輸,應該也是能夠實現要求的。github

同時相比較TCP協議實現的可靠傳輸,Unix Socket做爲IPC有些優勢:web

  • Unix Socket僅僅複製數據,並不執行協議處理,不須要添加或刪除網絡報頭,無需計算校驗和,不產生順序號,也不須要發送確認報文
  • 僅依賴命名管道,不佔用端口

Unix Socket並非一種協議,它是進程間通訊(IPC)的一種方式,解決本機的兩個進程通訊編程

在Node.js的http模塊和net模塊,都提供了相關接口 「listen(path, cb)」,不一樣的是http模塊在Unix Socket之上封裝了HTTP的協議解析及相關規範,所以這是能夠無縫兼容基於TCP實現的HTTP服務的。服務器

下爲基於Unix Socket的HTTP Server與Client 樣例:websocket

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,可參考個人另外一篇文章 Nodejs cluster模塊深刻探究 的「多個子進程與端口複用」一節。session

// 子進程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

// 初始化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模塊深刻探究「請求分發策略」一節)。可爲什麼在本文的實現中仍採用child_process模塊呢?

答案是:場景不一樣。做爲代理服務,它可使用cluster模塊實現代理服務的集羣;而針對業務服務,在session的場景中須要由代理服實現對應的轉發策略,其餘狀況則採用RoundRobin策略便可,所以child_process模塊更爲合適。

本文並未實現代理服務的負載均衡策略,其實現仍然在 Nodejs cluster模塊深刻探究 中講述,所以可參閱此文。

最終,在保持進程模型穩定的前提下,變動了底層協議可實現更高性能的代理服務。
本文代碼proxy-based-unixsocket
enter image description here

相關文章
相關標籤/搜索