使用Go語言創建WebSocket服務

  • 2020 年 3 月 26 日
  • 筆記

今天介紹如何用Go語言創建WebSocket服務,文章的前兩部分簡要介紹了WebSocket協議以及用Go標準庫如何創建WebSocket服務。第三部分實踐環節我們使用了gorilla/websocket庫幫助我們快速構建WebSocket服務,它幫封裝了使用Go標準庫實現WebSocket服務相關的基礎邏輯,讓我們能從繁瑣的底層程式碼中解脫出來,根據業務需求快速構建WebSocket服務。

Go Web 編程系列的每篇文章的源程式碼都打了對應版本的軟體包,供大家參考。公眾號中回復gohttp10獲取本文源程式碼

WebSocket介紹

WebSocket通訊協議通過單個TCP連接提供全雙工通訊通道。與HTTP相比,WebSocket不需要你為了獲得響應而發送請求。它允許雙向數據流,因此您只需等待伺服器發送的消息即可。當Websocket可用時,它將向您發送一條消息。對於需要連續數據交換的服務(例如即時通訊程式,在線遊戲和實時交易系統),WebSocket是一個很好的解決方案。 WebSocket連接由瀏覽器請求,並由伺服器響應,然後建立連接,此過程通常稱為握手。 WebSocket中的特殊標頭僅需要瀏覽器與伺服器之間的一次握手即可建立連接,該連接將在其整個生命周期內保持活動狀態。 WebSocket解決了許多實時Web開發的難題,並且與傳統的HTTP相比,具有許多優點:

  • 輕量級報頭減少了數據傳輸開銷。
  • 單個Web客戶端僅需要一個TCP連接。
  • WebSocket伺服器可以將數據推送到Web客戶端。

WebSocket協議實現起來相對簡單。它使用HTTP協議進行初始握手。握手成功後即建立連接,WebSocket實質上使用原始TCP讀取/寫入數據。

客戶端請求如下所示:

GET /chat HTTP/1.1      Host: server.example.com      Upgrade: websocket      Connection: Upgrade      Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==      Sec-WebSocket-Protocol: chat, superchat      Sec-WebSocket-Version: 13      Origin: http://example.com

這是伺服器響應:

HTTP/1.1 101 Switching Protocols      Upgrade: websocket      Connection: Upgrade      Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=      Sec-WebSocket-Protocol: chat

如何在Go中創建WebSocket應用

要基於Go 語言內置的net/http 庫編寫WebSocket伺服器,你需要:

  • 發起握手
  • 從客戶端接收數據幀
  • 發送數據幀給客戶端
  • 關閉握手

發起握手

首先,讓我們創建一個帶有WebSocket端點的HTTP處理程式:

// HTTP server with WebSocket endpoint  func Server() {          http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {              ws, err := NewHandler(w, r)              if err != nil {                   // handle error              }              if err = ws.Handshake(); err != nil {                  // handle error              }          …

然後初始化WebSocket結構。

初始握手請求始終來自客戶端。伺服器確定了WebSocket請求後,需要使用握手響應進行回復。

請記住,你無法使用http.ResponseWriter編寫響應,因為一旦開始發送響應,它將關閉其基礎的TCP連接(這是HTTP 協議的運行機制決定的,發送響應後即關閉連接)。

因此,您需要使用HTTP劫持(hijack)。通過劫持,可以接管基礎的TCP連接處理程式和bufio.Writer。這使可以在不關閉TCP連接的情況下讀取和寫入數據。

// NewHandler initializes a new handler  func NewHandler(w http.ResponseWriter, req *http.Request) (*WS, error) {          hj, ok := w.(http.Hijacker)          if !ok {              // handle error          }                  .....  }

要完成握手,伺服器必須使用適當的頭進行響應。

// Handshake creates a handshake header      func (ws *WS) Handshake() error {            hash := func(key string) string {              h := sha1.New()              h.Write([]byte(key))              h.Write([]byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))            return base64.StdEncoding.EncodeToString(h.Sum(nil))          }(ws.header.Get("Sec-WebSocket-Key"))        .....  }

客戶端發起WebSocket連接請求時用的Sec-WebSocket-key是隨機生成的,並且是Base64編碼的。接受請求後,伺服器需要將此密鑰附加到固定字元串。假設秘鑰是x3JJHMbDL1EzLkh9GBhXDw==。在這個例子中,可以使用SHA-1計算二進位值,並使用Base64對其進行編碼。得到HSmrc0sMlYUkAGmm5OPpG2HaGWk=。然後使用它作為Sec-WebSocket-Accept 響應頭的值。

傳輸數據幀

握手成功完成後,您的應用程式可以從客戶端讀取數據或向客戶端寫入數據。WebSocket規範定義了一個客戶機和伺服器之間使用的特定幀格式。這是框架的位模式:

圖:傳輸數據幀的位模式

使用以下程式碼對客戶端有效負載進行解碼:

// Recv receives data and returns a Frame      func (ws *WS) Recv() (frame Frame, _ error) {          frame = Frame{}          head, err := ws.read(2)          if err != nil {              // handle error          }

反過來,這些程式碼行允許對數據進行編碼:

// Send sends a Frame      func (ws *WS) Send(fr Frame) error {          // make a slice of bytes of length 2          data := make([]byte, 2)            // Save fragmentation & opcode information in the first byte          data[0] = 0x80 | fr.Opcode          if fr.IsFragment {              data[0] &= 0x7F          }          .....

關閉握手

當各方之一發送狀態為關閉的關閉幀作為有效負載時,握手將關閉。可選的,發送關閉幀的一方可以在有效載荷中發送關閉原因。如果關閉是由客戶端發起的,則伺服器應發送相應的關閉幀作為響應。

// Close sends a close frame and closes the TCP connection  func (ws *Ws) Close() error {      f := Frame{}      f.Opcode = 8      f.Length = 2      f.Payload = make([]byte, 2)      binary.BigEndian.PutUint16(f.Payload, ws.status)      if err := ws.Send(f); err != nil {          return err      }      return ws.conn.Close()  }

使用第三方庫快速構建WebSocket服務

通過上面的章節可以看到用Go自帶的net/http庫實現WebSocket服務還是太複雜了。好在有很多對WebSocket支援良好的第三方庫,能減少我們很多底層的編碼工作。這裡我們使用gorilla web toolkit家族的另外一個庫gorilla/websocket來實現我們的WebSocket服務,構建一個簡單的Echo服務(echo意思是迴音,就是客戶端發什麼,服務端再把消息發回給客戶端)。

我們在http_demo項目的handler目錄下新建一個ws子目錄用來存放WebSocket服務相關的路由對應的請求處理程式。

增加兩個路由:

  • /ws/echo echo應用的WebSocket服務的路由。
  • /ws/echo_display echo應用的客戶端頁面的路由。

創建WebSocket服務端

// handler/ws/echo.go  package ws    import (      "fmt"      "github.com/gorilla/websocket"      "net/http"  )    var upgrader = websocket.Upgrader{      ReadBufferSize:  1024,      WriteBufferSize: 1024,  }    func EchoMessage(w http.ResponseWriter, r *http.Request) {      conn, _ := upgrader.Upgrade(w, r, nil) // 實際應用時記得做錯誤處理        for {          // 讀取客戶端的消息          msgType, msg, err := conn.ReadMessage()          if err != nil {              return          }            // 把消息列印到標準輸出          fmt.Printf("%s sent: %sn", conn.RemoteAddr(), string(msg))            // 把消息寫回客戶端,完成迴音          if err = conn.WriteMessage(msgType, msg); err != nil {              return          }      }  }
  • conn變數的類型是*websocket.Conn, websocket.Conn類型用來表示WebSocket連接。伺服器應用程式從HTTP請求處理程式調用Upgrader.Upgrade方法以獲取*websocket.Conn
  • 調用連接的WriteMessageReadMessage方法發送和接收消息。上面的msg接收到後在下面又回傳給了客戶端。msg的類型是[]byte

創建WebSocket客戶端

前端頁面路由對應的請求處理程式如下,直接返回views/websockets.html給到瀏覽器渲染頁面即可。

// handler/ws/echo_display.go  package ws    import "net/http"    func DisplayEcho(w http.ResponseWriter, r *http.Request) {      http.ServeFile(w, r, "views/websockets.html")  }

websocket.html里我們需要用JavaScript連接WebScoket服務進行收發消息,篇幅原因我就只貼JS程式碼了,完整的程式碼通過本節的口令去公眾號就能獲取到下載鏈接。

<form>      <input id="input" type="text" />      <button onclick="send()">Send</button>      <pre id="output"></pre>  </form>  ...  <script>      var input = document.getElementById("input");      var output = document.getElementById("output");      var socket = new WebSocket("ws://localhost:8000/ws/echo");        socket.onopen = function () {          output.innerHTML += "Status: Connectedn";      };        socket.onmessage = function (e) {          output.innerHTML += "Server: " + e.data + "n";      };        function send() {          socket.send(input.value);          input.value = "";      }  </script>  ...

註冊路由

服務端和客戶端的程式都準備好後,我們按照之前約定好的路徑為他們註冊路由和對應的請求處理程式:

// router/router.go  func RegisterRoutes(r *mux.Router) {      ...      wsRouter := r.PathPrefix("/ws").Subrouter()      wsRouter.HandleFunc("/echo", ws.EchoMessage)      wsRouter.HandleFunc("/echo_display", ws.DisplayEcho)  }

測試驗證

重啟服務後訪問http://localhost:8000/ws/echo_display,在輸入框中輸入任何消息都能再次回顯到瀏覽器中。

圖片

服務端則是把收到的消息列印到終端中然後把調用writeMessage把消息再回傳給客戶端,可以在終端中查看到記錄。

總結

WebSocket在現在更新頻繁的應用中使用非常廣泛,進行WebSocket編程也是我們需要掌握的一項必備技能。文章的實踐練習稍微簡單了一些,也沒有做錯誤和安全性檢查。主要是為了講清楚大概的流程。關於gorilla/websocket更多的細節在使用時還需要查看官方文檔才行。