使用 Go 語言建立 WebSocket 服務

KevinYan發表於2020-03-16

今天介紹如何用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規範定義了的一個客戶機和伺服器之間使用的特定幀格式。這是框架的位模式:

img{512x368}
圖:傳輸資料幀的位模式

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

// 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: %s\n", 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: Connected\n";
    };

    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把訊息再回傳給客戶端,可以在終端中檢視到記錄。

image-20200316142506287

總結

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

參考連結:

https://yalantis.com/blog/how-to-build-web...
https://www.gorillatoolkit.org/pkg/websock...

前文回顧

深入學習用Go編寫HTTP伺服器

超詳細的Go模板庫應用指南

用Go語言建立靜態檔案伺服器

用SecureCookie實現客戶端Session管理

img

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章