Go, JS和Websocket

JS中創建Websocket鏈接

var ws = new WebSocket("ws://hostname/path", ["protocol1", "protocol2"])

參數說明

第一個參數是服務端websocket地址,若是是https+websocket,那麼前綴寫成wssjavascript

第二個參數並非必須的,它約定了雙方通信使用的自定義子協議,會被放到這個Header中: Sec-WebSocket-Protocolhtml

子協議在某些場合是很必要的,例如服務端要與多個客戶端版本兼容,那麼若干個版本以後,服務端設定支持子協議 v1.5, v2.0, 而客戶端發送的倒是 v1.0,那麼他們就能夠在握手階段失敗,不會繼續通訊下去致使奇奇怪怪的錯誤。java

攜帶額外信息及認證

WebSocket構造函數只有兩個變量,不能提供經過設置自定義Header的方式來攜帶其它信息,但仍能夠經過一些取巧的辦法攜帶額外的信息,用於認證等:git

  1. 經過ws地址填寫形如 ws://username:password@hostname/path, 即構造出了 Authorization Headergithub

  2. 經過ws地址填寫形如 ws://:password@hostname/path ,即構造出了 Bearer Token Headergolang

  3. 經過在Cookie中加入值,也可以攜帶額外的信息web

所以,在服務端設計握手階段認證時,應當避免使用這三種方式外攜帶的信息來進行認證(例如設置一個自定義的頭部),固然也能夠在websocket鏈接創建後,再經過自定義的認證協議,走websocket進行認證。瀏覽器

Go中提供Websocket服務

Google本身提供一個Websocket包 : golang.org/x/net/websocket緩存

不過他們親口認可這個包缺少一些特性,也缺少維護,他們推薦用 github.com/gorilla/websocket (原文見 https://godoc.org/golang.org/x/net/websocket)服務器

// 這裏代碼使用了go-restful 做爲http框架,換成http也無妨
    conn, err := websocket.Upgrade(resp.ResponseWriter, req.Request, nil, 0, 0)
    if err != nil {
        resp.WriteError(http.StatusBadRequest, err)
        return
    }
    defer conn.Close()

也能夠手動建立一個 Upgrader 來處理子協議協商問題, 若是協商經過,就能夠很容易的得到最終協商好的子協議,從而使用正確版本的數據格式和處理方法。

數據發送

WebSocket發送的數據都是「幀(Frames)」,主要有這麼幾種:

  • 持續幀(用於數據分片,通常不明確使用)
  • 文本幀(傳輸文本數據)
  • 二進制幀(傳輸二進制數據)
  • Ping/Pong幀 (用於心跳等,簡單檢測鏈接存活狀態)
  • 控制幀(關閉鏈接等)

JS中提供了send方法,可以發送文本幀或二進制幀:

ws.send('{"abc":"def"}')

經過調用 ws.close(code, msg), 能夠發送關閉信息,若是不提供,那麼默認code爲1005(正常關閉),而不明確關閉,那麼服務端收到的多是1006.

當服務端對鏈接發起Ping時,瀏覽器中活躍的WebSocket對象會自動回覆Pong, 這能夠用於鏈接的活躍檢測。

服務端向客戶端發送的數據就要自由的多了,在此很少講,參考包文檔便可。

數據接收

ws.onmessage = function(event) {
            graphData = JSON.parse(event.data)
            console.log('Received graph data:', graphData)
            if(graphData.error != null) {
                loadGraphData(null, graphData.error)
                return
            }
            loadGraphData(graphData, 'success')
        };

在服務端的數據接收通常須要一個單獨的go routine進行處理,可使用 NextReader, ReadMessage, ReadJSON這幾個方法進行讀取。須要注意的是,對於同一個WebSocket鏈接, 這些讀取方法應當在同一個go routine中順序執行,不然讀取操做將致使上一個進行中的讀取失敗。

WebSocket的關閉

JS和Go中都提供了WebSocket的關閉事件監聽,如:

ws.onclose = function(event) {
            if (event.wasClean) {
                //alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
            } else {
                console.log('WSError:', event)
                showError('服務暫不可用,請稍後刷新')
            }
        };
conn.SetCloseHandler(func(code int, msg string) error {
        c.closed <- msg
        return nil
    })

可是須要知道的是,WebSocket的關閉事件是基於收到明確的關閉消息狀況下才會出現的。換言之, 你在服務端監聽WebSocket關閉事件,當瀏覽器頁面刷新或關閉時,服務端並不能及時發現舊的WebSocket已經關閉,甚至向它們發送信息仍然不會收到任何錯誤(瀏覽器不關閉的狀況下)。
一樣的,當服務器忽然關閉,而關閉前又沒有明確關閉全部WebSocket鏈接時,那麼在JS中寫的onclose事件也不會起做用。

所以,服務端要想避免無用的WebSocket佔用資源,應當維護一種心跳機制,而WebSocket協議已經提供了Ping/Pong幀用於作這件事,而JS中的WebSocket對象也默認可以迴應Ping幀,所以心跳方案是:

  • 在服務端創建WebSocket鏈接後,每隔一個心跳週期T,向鏈接發送Ping,設定迴應時限小於T(建議設置爲 T/2)
  • 當Ping返回錯誤時,代表客戶端已離線,或因種種緣由未能在時限內迴應Ping,服務端關閉鏈接,並將其移除
heartbeat := time.Duration(s.cfg.Heartbeat) * time.Second
    tick := time.NewTicker(heartbeat)
    for {
        select {
        case <-tick.C:
            err := c.WriteControl(websocket.PingMessage, []byte(key), time.Now().Add(heartbeat/2))
            if err != nil {
                log.Printf("Websocket Idle connection %s: Ping received error %s", key, err.Error())
                return
            }
        case <-s.ctx.Done():
            log.Printf("Websocket closed for client %s: server is closing\n", key)
            return
        case msg := <-c.closed:
            log.Printf("Websocket closed for client %s: %s\n", key, msg)
            return
        }
    }

這種方案的優勢是利用了JS自帶的Pong迴應,不須要寫額外的JS代碼,而服務端實現也比較簡單。

先前網上查了一些方案,是在客戶端藉助send發送數據幀來進行心跳,一樣的服務端也要單獨針對這種數據幀進行處理,有些過於複雜了。

JS防止堵塞

WebSocket鏈接創建後,客戶端與服務端之間創建了一條長鏈接,其後全部的數據通信都要在這一個socket上傳輸。當客戶端頻繁發送數據時,數據就可能會堵塞在本地,JS中提供了一種方法能夠限制發送速率:

// 每隔100毫秒發送數據, 這裏限制爲只有在沒有緩存時纔會發送數據
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

經過讀取 bufferedAmount 就能夠知道當前鏈接是否堵塞, 能夠決定是否繼續發送。

JS中的錯誤處理

握手階段出現錯誤:

若是因爲子協議協商等緣由,WebSocket未能成功升級,那麼會在 onclose 事件中收到消息

其它錯誤在ws.onerror 中處理

ws.onclose 事件接收到的 event主要有 wasClean(bool), code(int), reason(string) 幾個成員,主要做用是:

wasClean 當js主動發起ws.close時,onclose接收到的事件中該值爲true,其餘狀況無論服務器以什麼狀態碼關閉,是false

code: 關閉時的狀態碼

reason: 關閉時發送的關閉消息,通常用於詳細說明狀況(如:服務器正在關閉/重啓,等等)

關閉

在服務端調用 websocket.Conn.Close() 的做用是關閉底層socket, 不能讓client收到有效的消息,正確的關閉應當手動發送Close消息:

conn.WriteControl(websocket.CloseMessage,
                websocket.FormatCloseMessage(websocket.CloseNormalClosure, "Server is closing"),
                time.Now().Add(time.Second))

同理,在JS客戶端,不須要使用websocket時應當手動關閉WebSocket,頁面刷新或離開時也應當關閉WebSocket:

<body onunload="leavePage()">
...
<script>
        function leavePage() {
            ws.close(1000, 'Client closed')
        }
</script>

WebSocket關閉的經常使用狀態碼,參見 websocket/conn.go 或RFC文檔,幾個常見的WebSocket關閉碼:

1000: 正常關閉
1001: 服務端暫停服務,或客戶端離開頁面
1005: 此次關閉沒有狀態碼
1006: 這是一次不正常關閉,沒有發送關閉幀

參考文檔

JS: https://javascript.info/websocket#opening-a-websocket

Go: https://godoc.org/github.com/gorilla/websocket

WebSocket: https://github.com/HJava/myBlog/tree/master/WebSocket%20%E5%8D%8F%E8%AE%AE%20RFC%20%E6%96%87%E6%A1%A3

相關文章
相關標籤/搜索