1. 程式人生 > >【Node/JavaScript】論一個低配版Web實時通訊庫是如何實現的1( WebSocket篇)

【Node/JavaScript】論一個低配版Web實時通訊庫是如何實現的1( WebSocket篇)

引論

simple-socket是我寫的一個"低配版"的Web實時通訊工具(相對於Socket.io),在參考了相關原始碼和資料的基礎上,實現了前後端實時互通的基本功能

選用了WebSocket ->server-sent-event -> AJAX輪詢這三種方式做降級相容,分為simple-socket-client和simple-socket-server兩套程式碼,

並實現了最簡化的API:

  • 前後端各自通過connect事件觸發,獲取各自的socket物件

  • 前端通過socket.emit('message', "data")傳送訊息; 服務端通過socket.on('message', function (data) { //... })接收

  • 服務端通過socket.emit('message', "data")傳送訊息; 前端通過socket.on('message', function (data) { //... })接收

為方便細節的理解,未直接引用ws,eventsource,sockjs,http://engine.io等已有的工具庫

下面把編碼的過程和細節,以及程式碼予以記錄

github倉庫地址

 

https://github.com/penghuwan/simple-socket

 

npm命令

npm i simple-socket-serve   (服務端npm包)
npm i simple-socket-client   (客戶端npm包)

使用方式(模仿Socket.io)

前端

var client = require('simple-socket-client');
var client = new Client();
client.on('connect', socket => {
    socket.on('reply', function (data) {
        console.log(data)
    })
    socket.emit('message', "pppppppp");
})

 

服務端

const SocketServer = require('simple-socket-serve');
const http = require('http');

const server = http.createServer(function (request, response) {
    // 你的其他程式碼~~
})

// Usage start
const ss = new SocketServer({
    httpSrv: server, // 需傳入Server物件
});
ss.on('connect', socket => {
    socket.on('message', data => {
        console.log(data);
    });
    setTimeout(() => {
        socket.emit('reply', "aaaa");
    }, 3000);
});
// Usage end

server.listen(3000);

 

Output

前端: 約3秒後輸出aaaa
服務端:輸出pppppp

 

下面梳理了我在編碼過程中的思路,其中有些是借鑑於已有的工具庫(如Socket.io)原始碼,有些則是自己的思考所得。如有錯漏之處請多指點

需要思考的問題

  1. 我們需要編寫哪些通訊方式?這些通訊方式的上到下的相容順序是什麼?

  2. 瀏覽器怎麼選擇最優的通訊方式呢?

  3. 服務端怎麼知道當前發出請求的瀏覽器,它最高支援哪一種通訊方式?

  4. 編寫的服務端程式碼怎麼和當前的業務程式碼銜接?

  5. 如何使用WebSocket實現通訊?

Q1. 我們需要編寫哪些通訊方式?這些通訊方式的上到下的相容順序是什麼?

 

首先要先梳理一下可供選擇的實現雙向通訊的方式,以及它們的瀏覽器相容性 (相容性資料來源於 can i use)

  • WebSocket: IE10以上才支援,Chrome16, FireFox11,Safari7以及Opera12以上完全支援,移動端形勢大

  • event-source: IE完全不支援(注意是任何版本都不支援),Edge76,Chrome6,Firefox6,Safari5和Opera以上支援, 移動端形勢大好

  • AJAX輪詢: 用於相容低版本的瀏覽器

  • 永久幀( forever iframe)可用於相容低版本的瀏覽器

  • flash socket 可用於相容低版本的瀏覽器

 

那麼它們的優缺點各是怎樣的呢?

1.WebSocket

  • 優點:WebSocket 是 HTML5 開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議,可從HTTP升級而來,瀏覽器和伺服器只需要一次握手,就可以進行持續的,雙向的資料傳輸,因此能顯著節約資源和頻寬

  • 缺點:1. 相容性問題:不支援較低版本的IE瀏覽器(IE9及以下)2.不支援斷線重連,需要手寫心跳連線的邏輯 3.通訊機制相對複雜

2. server-sent-event(event-source)

  • 優點:(1)只需一次請求,便可以stream的方式多次傳送資料,節約資源和頻寬 (2)相對WebSocket來說簡單易用 (3)內建斷線重連功能(retry)

  • 缺點: (1)是單向的,只支援服務端->客戶端的資料傳送,客戶端到服務端的通訊仍然依靠AJAX,沒有”一家人整整齊齊“的感覺(2)相容性令人擔憂,IE瀏覽器完全不支援

3. AJAX輪詢

  • 優點:相容性良好,對標低版本IE

  • 缺點:請求中有大半是無用的請求,浪費資源

4.Flash Socket(這個感覺得先說缺點2333)

  • 缺點:(1)瀏覽器開啟時flash需要使用者確認,(2)載入時間長,使用者體驗較差 (3)大多數移動端瀏覽器不支援flash,為重災區

  • 優點: 相容低版本瀏覽器

          

 

5. 永久幀( forever iframe)

  • 缺點: iframe會產生進度條一直存在的問題,使用者體驗差

  • 優點:相容低版本IE瀏覽器

 

綜上,綜合相容性和使用者體驗的問題,我在專案中選用了WebSocket ->server-sent-event -> AJAX輪詢這三種方式做從上到下的相容

 

Q2: 瀏覽器端怎麼選擇最優的通訊方式呢?

很簡單,做一下能力檢測就可以了,對於支援WebSocket的瀏覽器,window頂層物件可以檢測到WebSocket屬性,而支援SSE的瀏覽器,則可以檢測到window.EventSource屬性,這便可以作為判斷依據。對三種方式做從上到下的判斷即可。

 

// 備註: 此為前端程式碼
function Client() { 
    this.ws = null;
    this.es = null;
    init.call(this);
}
function init() {
    // 採用WebSocket作為通訊方式
    if (window.WebSocket) {
        this.type = 'websocket';
        this.ws = new WebSocket(`ws://${url}`);
        return;
    }
   // 採用server-sent-event作為通訊方式
    if (window.EventSource) {
        this.type = 'eventsource';
        this.es = new EventSource(`http://${url}/eventsource?connection=true`)
        return;
    }
   // 採用Ajax輪詢作為通訊方式
    this.type = 'polling';
}

 

Q3.服務端怎麼知道當前發出請求的瀏覽器,它最高支援哪一種通訊方式?

因為服務端需要處理不同的瀏覽器發出的請求,這些請求的方式可能是不一樣的。

我的思路是:

  1. 對於websocket請求,可通過檢測connection首部欄位是否包含'upgrade',同時upgrade首部欄位是否為 'websocket'這兩個條件進行判斷

  2. 對於event-source和AJAX輪詢,讓前端選擇方式後,傳URL路徑過去告知後端就可以了,路徑分別為host:/eventsource和host:/polling

  3. event-source我覺得也可以在前端設定accept:'text/event-stream'的方式告知後端,這個待會改改

 

// 備註:Node.js服務端程式碼
var url = require('url');
module.exports = {
    // 判斷請求的瀏覽器是否選擇了websocket進行通訊
    isWebSocket(req) {
        var connection = req.headers.connection || '';
        var upgrade = req.headers.upgrade || '';
        return connection.toLowerCase().indexOf('upgrade') >= 0 &&
            upgrade.toLowerCase() === 'websocket';
    },
    // 判斷請求的瀏覽器是否選擇了event-source(SSE)進行通訊
    isEventSource(req) {
        var pathname = url.parse(req.url).pathname;
        return pathname === '/eventsource';
    },
    // 判斷請求的瀏覽器是否選擇了AJAX輪詢進行通訊
    isPolling(req) {
        var pathname = url.parse(req.url).pathname;
        return pathname === '/polling';
    },
}

 

Q4. 編寫的服務端程式碼怎麼和當前的業務程式碼銜接?

我們定義一個SocketServer類,並在contructor中接收業務程式碼中已有的server例項,並監聽其request事件去處理請求和響應。如下所示

 

// 備註: Node.js服務端程式碼
class SocketServer {
  constructor (opt) {
    super();
    // 以建構函式引數的方式接收業務程式碼裡面已有的Server例項
    this.httpSrv = opt.httpSrv;
    this._initHttp(); 
  }
  _initHttp() {
    // 監聽外部Server例項的request事件,並處理請求和響應
    this.httpSrv.on('request', (req,res) => {
      // ...
    } );
  }
}

使用方式

const server = http.createServer(function (request, response) {    }) // 原有的業務程式碼
const ss = new SocketServer({
    httpSrv: server, // 需傳入Server物件
});
ss.on('connect', socket => {   });

 

這樣做有兩個好處:

  • 一方面,對原有的程式碼沒有過多的侵入性

  • 避免了建立新的server例項或監聽不同的埠,保持和原server同域,避免了前後端程式碼產生跨域的問題

前後端組織邏輯概述

 

前端

1.定義建構函式Client

 

function Client(host) {
    this.type = null; // 通訊方式
    this.ws = null; // WebSocket物件
    this.es = null; // EventSource物件
    this.ajax = null;
    init.call(this); // 通過能力檢測, 設定this.type,初始化相關API物件
    listen.call(this); // 監聽相關連線開啟或訊息接收的事件(例如ws.onpen/ws.onmessage;
}
Client.prototype.on  = function (event,cb){
        emitter.on(event, cb)
}

 

2.在連線開啟時觸發connect事件,把client物件自身給傳進去

this.ws.onopen = function () {
  emitter.emit('connect', this);
}

var client = new Client();
// 下面的寫法中,socket和client其實是同一個物件
client.on('connect', socket => {
    socket.on('reply', function (data) {
        console.log(data)
    })
    socket.emit('message', "pppppppp");
})

 

 

 

 後端   定義一個Socket類,每個請求會對應建立一個Socket物件(對於AJAX輪詢時候考慮複用Socket物件)
class Socket extends events.EventEmitter {
 constructor(socketId) {
   super();
   this.transport = null;  // 標記通訊方式 
   this.id = socketId;     // SocketId
   this.netSocket = null // updrage時獲取的net.socket的例項,供WebSocket通訊使用
   this.eventStream = null // Stream.readable例項,供Event-Source通訊使用
   this.toSendMes = [];    // 待發送的資訊,HTTP輪詢時使用
  }
  // 其他程式碼 ...
  on (event,cb) {
    // 接收前端傳送的資訊
  }
  emit (event,data) {
    // 傳送資訊給前端
  }
}

 

並且定義Server類如下:
class Server extends events.EventEmitter {
 constructor(opt) {
   super();
   this.httpSrv = opt.httpSrv;
   // ...
  }
  // 其他程式碼 ...
}

//  使用Server物件
const ss = new Server({
    httpSrv: server, // 需傳入Server物件
});
ss.on('connect', socket => {
    socket.on('message', data => {
        console.log(data);
    });
    socket.emit('reply', "aaaa");
});

 

Server物件會根據每請求建立相應Socket物件(AJAX輪詢中Socket物件可能持久化並複用 並且是繼承自events.EventEmitter,它會在適當的時刻觸發connect事件,並且把請求對應的Socket物件傳過去

Q5.如何實現WebSocket實時通訊?

關於如何在前端利用WS傳送和接收訊息,MDN文件裡說得很詳細了請看 https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket這裡不再贅述,主要是用了這幾個API:
  • 建立websocket物件:var ws = new WebSocket(url);
  • 傳送訊息 ws.send("XXXX");
  • 接收訊息:ws.onmessage = function (payload) { console.log(payload.data) };

 

WebSocket前端程式碼

前端接收訊息

// 一開始能力檢測的時候判斷過通訊型別並初始化
this.ws = new WebSocket(`ws://${url}`); 
// ... 中間隔了其他程式碼
this.ws.onmessage = function (payload) {
  var  dataObj = JSON.parse(payload.data);
  emitter.emit(dataObj.event, dataObj.data);  // 觸發事件
}

前端傳送訊息

// 一開始能力檢測的時候判斷過通訊型別並初始化
this.ws = new WebSocket(`ws://${url}`); 
// ... 中間隔了其他程式碼
this.ws.send(JSON.stringify({
  event: event,
  data: data
}));

 

WebSocket服務端程式碼(Node.js)

WebSocket的報文結構

接下來要講的是後端怎麼進行websocket訊息的傳送和接收。這首先要先從websocet請求報文和響應報文開始說起 1.這是我的ws請求報文
Connection: Upgrade  // 表示請求從HTTP升級為其他協議
Upgrade: websocket  // 表示升級的協議是webSocket
Sec-WebSocket-Key: VCKjclrCsM3LpMkEngmVhA== // 這個引數需要在服務端拼接後返回
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits //  WebSocket的擴充套件欄位
Sec-WebSocket-Version: 13 // WebSocket版本
Sec-websocket-protocol //這個欄位我的報文裡沒有,它是前端webSocket建構函式指定的第二個引數(new WebSocket(url,[protocol]))

 

2.這是我的ws響應報文
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: WLZzo5hbAQgXJ24D0mE3u3nj1Fo=

...

WebSocket的握手流程和程式碼

 

要在後端完成基本的握手,你需要做這三件事情: 1.監聽server物件的upgrade方法,從回撥中接收請求物件req和socket物件,接下來通過req判斷是否該請求是否是一個webSocket請求,如果是則進行下一步處理
2. 把下面這三行欄位原封不動地寫入響應報文裡,準備返回去給前端~~
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket

3. 從前端請求報文中獲取Sec-WebSocket-Key,拼接上服務端自己定義的ID字串,然後用sha1加密,再然後轉為base64編碼格式。最後放在Sec-WebSocket-Accept這個響應報文欄位中返回給前端。返回資料的方法是呼叫socket.write方法
上面三件事完成了,基本的握手流程就可以跑通了
如果你想進一步知道怎麼對Sec-WebSocket-Extensions,Sec-websocket-protocol這幾個請求欄位做處理,你可以看看這裡,這個是ws模組的程式碼 https://github.com/websockets/ws/blob/master/lib/websocket-server.js ,對,就是這個檔案
  下面是握手流程具體程式碼
class SocketServer {
  constructor (opt) {
    super();
    // 以建構函式引數的方式接收業務程式碼裡面已有的Server例項
    this.httpSrv = opt.httpSrv;
    this._initWebSocket();
  }

  _initWebSocket() {
      // 監聽upgrade事件,判斷是否請求是websocket,若是則進行握手
      this.httpSrv.on('upgrade', (req, netSocket) => {
        // ... other code
        this._handleWShandShake(req, netSocket, () => {
          const socket = new Socket(null);
          // 握手成功後觸發onConnection事件, 同時傳遞socket物件進去
          this.emit('connect', socket);
        })
      });
  }
}

 

上面的_handleWShandShake方法程式碼如下:

handleWShandShake(req, netSocket, cb) {
    if (!detect.isWebSocket(req)) {
        return;
    }
    const key =
        req.headers['sec-websocket-key'] !== undefined
            ? req.headers['sec-websocket-key'].trim()
            : '';
    const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    const digest = createHash('sha1')
        .update(key + GUID)
        .digest('base64');
    const headers = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        `Sec-WebSocket-Accept: ${digest}`
    ];

    netSocket.write(headers.concat('\r\n').join('\r\n'));
    cb();
}

 

上面講了websocket的握手過程,下面講一下怎麼進行server端訊息的傳送和接收

服務端接收訊息

我們上回說到,監聽server物件的upgrade事件可以獲取socket物件,我們可以通過監聽socket物件的data方法,獲取前端通過websocket.send傳來的資料 。

 

但是這裡有一個坑!上面data的回撥裡接收的payload是一個Buffer型別的物件,那我們能否通過Buffer.string去獲得前端傳來的JSON字串呢?

答案是

 

 

因為傳來的—— 是一個封裝好的幀的資料,你需要把它手動解析出來,才能取出我們想要的那部分資料。

(如果你發現報了failed: One or more reserved bits are on: reserved1 = 1, reserved2 = 1, reserved3 = 1 這個錯誤,恭喜你!踩中坑了)

WebSocket幀的編碼和解碼

在介紹幀的編碼和解碼之前,讓我們先看看WebSocket的幀的格式是怎樣的

WebSocket的幀格式

 

 

詳細介紹參考Websocket的RFC文件:https://tools.ietf.org/html/rfc6455 (在page27處)

 

瞭解了websocket幀的格式後,這裡介紹一下幾個非(jin)常(chang)有(keng)用(ren)的欄位

  • FIN: 表示是否是最後一個幀,1代表是,0不是 // 返回資料幀給前端的時候FIN一定要為1,不然前端收不到

  • Opcode:幀型別,1代表文字資料,2代表二進位制資料 // 這個影響前端onmessage接收的資料型別到底是String還是Blob

  • RSV 1 RSV2 RSV3 留以後備用 //也就是。。現在還沒有卵用,如果控制檯報了這個有錯八成是沒有解析幀資料

其他一些欄位

  • Mask :1bit 掩碼,是否加密資料,預設必須置為1

  • Payload len : 7bit,表示資料的長度

  • Payload data :為資料內容

解析資料幀的程式碼

OK!介紹完了幀的格式,下面show一下(別人的)解析幀的程式碼

 

// 解析Socket資料幀的方法
// 作者:龍恩0707 
// 參考地址: https://www.cnblogs.com/tugenhua0707/p/8542890.html
function decodeFrame(e) {
    var i = 0, j, s, arrs = [],
        frame = {
            // 解析前兩個位元組的基本資料
            FIN: e[i] >> 7,
            Opcode: e[i++] & 15,
            Mask: e[i] >> 7,
            PayloadLength: e[i++] & 0x7F
        };

    // 處理特殊長度126和127
    if (frame.PayloadLength === 126) {
        frame.PayloadLength = (e[i++] << 8) + e[i++];
    }
    if (frame.PayloadLength === 127) {
        i += 4; // 長度一般用4個位元組的整型,前四個位元組一般為長整型留空的。
        frame.PayloadLength = (e[i++] << 24) + (e[i++] << 16) + (e[i++] << 8) + e[i++];
    }
    // 判斷是否使用掩碼
    if (frame.Mask) {
        // 獲取掩碼實體
        frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];
        // 對資料和掩碼做異或運算
        for (j = 0, arrs = []; j < frame.PayloadLength; j++) {
            arrs.push(e[i + j] ^ frame.MaskingKey[j % 4]);
        }
    } else {
        // 否則的話 直接使用資料
        arrs = e.slice(i, i + frame.PayloadLength);
    }
    // 陣列轉換成緩衝區來使用
    arrs = new Buffer.from(arrs);
    // 如果有必要則把緩衝區轉換成字串來使用
    if (frame.Opcode === 1) {
        arrs = arrs.toString();
    }
    // 設定上資料部分
    frame.PayloadLength = arrs;
    // 返回資料幀
    return frame;
}

 

幀解碼後接收前端傳來的訊息

幀解碼

藉助於上面的decodeFrame方法,我們就可以愉快地通過WebSocket從前端接收訊息啦!

 

this.httpSrv.on('upgrade', (req, netSocket) => {
        // ... other code
       netSocket.on('data', payload => {
              // 對接收的WebSocket幀資料進行解析,對應前端呼叫ws.send方法發來的資料
              const str = decodeFrame(payload).PayloadLength;
        });
});

 

通過WebSocket向前端傳送訊息

根據上文容易聯想,既然接收訊息要解析幀,那麼傳送訊息也肯定要把資料封裝成幀再發送對不對~~ 看程式碼

WebSocket幀的封裝

// 接收資料並返回Socket資料幀的方法
// 作者:小鬍子哥
// 參考地址: https://www.cnblogs.com/hustskyking/p/websocket-with-node.html
function encodeFrame(e) {
    var s = [], o = Buffer.from(e.PayloadData), l = o.length;
    //輸入第一個位元組
    s.push((e.FIN << 7) + e.Opcode);
    //輸入第二個位元組,判斷它的長度並放入相應的後續長度訊息
    //永遠不使用掩碼
    if (l < 126) s.push(l);
    else if (l < 0x10000) s.push(126, (l & 0xFF00) >> 8, l & 0xFF);
    else s.push(
        127, 0, 0, 0, 0, //8位元組資料,前4位元組一般沒用留空
        (l & 0xFF000000) >> 6, (l & 0xFF0000) >> 4, (l & 0xFF00) >> 8, l & 0xFF
    );
    //返回頭部分和資料部分的合併緩衝區
    return Buffer.concat([new Buffer(s), o]);
}

 

好的大夥,故事到這裡就講完了,祝大家 。。。

等等!!

好像還有什麼重要的事情要說。

WebSocket編碼的技術總結

下面開始WebSocket編碼的技術總結~(美食作家王剛的口音)

 

 

「Node篇」

  1. httpServer的Upgrade事件並不是Upgrade成功時觸發的,而是包含Upgrade首部的請求報文到達服務端時觸發的,也即每次伺服器響應升級請求時發出。我們可以在這裡確認請求是否為Websocket升級請求並進行握手

  2. 在simple-socket-server中,是將其附加到已有的server例項中根據其自有的請求和響應進行處理,而不是另外啟動一個server,這樣是為了避免產生跨域的問題,因為simple-socket-client的JS程式碼和專案本身的服務端程式碼是同域的,simple-socket-server自然也要和原有的服務端程式碼同域

  3. 可以通過httpserver物件的request事件監聽請求和響應,從外部附加socket-server的業務程式碼

「WebSocket篇」

 

  1. websocket不是永久連線的。一段時間就會斷開,websocket需要手寫定時心跳連線的程式碼(待會填上去)

  2. 服務端接收Websocket資料需手動解析WebSocket幀。當你嘗試接收前端的資料時,即在服務端獲取到連線的socket後,通過socket.on('data', payload => { ... })獲取的payload。這個payload是一個Buffer型別, 然而蛋疼的是你也不能直接通過Buffer.toString拿到這個字串資料,如果直接toString輸出將會得到一串亂碼!!因為收到的這個Buffer是一個被封裝後的幀,需要進行解析

  3. 服務端傳送Websocket資料需手動封裝WebSocket幀。 正如上一條所示,在websocket的服務端,你不能直接通過socket.write(String)或者socket.write(Buffer)去寫資料,而是要手動先把資料封裝成幀,才能傳送過去

  4. 在服務端傳送websocket資料幀時,要確保FIN為1(表示最後一個幀)。前端onmessage才能收到響應!否則無法響應。

  5. WebSocket的onmessage = (event) =>{ event.data }中前端接收的event.data的型別取決於服務端返回的資料幀的opcode這一欄位, event.data可能為Blob (opcode = 2,代表傳送過去的是二進位制資料) 或者字串(opcode = 1,表示字串資料)

本文完,完整程式碼請參考

 

github倉庫地址

 

https://github.com/penghuwan/simple-socket