iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 25
1
Software Development

下班加減學點Golang與Docker系列 第 25

Go Websocket 長連線

WebSocket

WebSocket(簡稱ws), 在2011年標準化成RFC6455.

WebSocket最新支援版本的查詢.
目前最新版本為13.

跟以往的API這種短連接相比, ws允許瀏覽器跟Server只需要經過一次的交握過程, 就可能建立起一條雙工的長連線, 並進行雙向資料傳輸.

WebSocket跟Http一樣也支援憑證, 協定開頭就從ws://變成wss://, 加上一層TLS保護.

因為該協定的出現跟瀏覽器支援的普及, 讓Server能夠主動的發訊息給瀏覽器.
而不用瀏覽器定期的Long polling來取最新資料,
因為很可能絕大部分資料都沒變更(搞不好超過7成請求都是), 而回應http 304.
Server也是要去查詢資料庫跟做比對.
有了這樣的雙向傳輸行為, 能夠作到類似觀察者模式的行為, 有異動才通知, 有需要才通知.
雙方的CPU能有更多的時間去處理更多其他的業務.

瀏覽器第一次請求, 會先發起一個http請求帶有Upgrade的Connection Header來到ws server.
還有Sec-WebSocket-Key, 這是一個base64的值, 這個會跟後面server回應的response是個成套的.
Sec-WebSocket-Version: 版本號, 就是ws的版本.

之後瀏覽器會回應一個status 101的回應給瀏覽器, 表示已經了解客戶端的請求, 讓它切換到改用ws protocol.
Sec-WebSocket-Accept就是經過server確認的base64 encode的值. 客戶端拿到就能解密看看是不是原來的伺服器發回來的了, 避免惡意的連結或是意外的連結.
Sec-WebSocket-Accept = base64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).

SecWebSocketAccept的計算

這樣就完成了ws的交握了.
然後就能來Open長連線.
為了偶爾確認對方的死活XD
ws會透過ping(0x9)和pong(0xA), 來彼此確認對方狀況.很像是心跳的收縮.
要是server收到ping在一定時間內沒回pong, cleint會視為server斷線而close()或是重連.

Go Websocket

安裝gorilla/websocket

go get github.com/gorilla/websocket

老樣子剛提到要先作http upgrade.
所以要有請求來處理, 建立一個websocketHandler.go

package handler

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
)

var upGrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func WsPing(ctx *gin.Context) {

	ws, err := upGrader.Upgrade(ctx.Writer, ctx.Request, nil)
	if err != nil {
		return
	}
	defer ws.Close()
	for {
		// 讀取ws Socket傳來的訊息
		mt, message, err := ws.ReadMessage()
		if err != nil {
			break
		}
		// 如果是ping
		if string(message) == "ping" {
			// 就回pong
			message = []byte("pong")
		} else {
			// 如果是其他, 就回文字訊息類型, 內容就是回聲 (鸚鵡XD) 
			ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintln("got it : "+string(message))))
		}
		// 寫入Websocket
		err = ws.WriteMessage(mt, message)
		if err != nil {
			break
		}
	}
}

當然也要有前端來發起http upgrade to ws request.
修改之前的index.tmpl, 加入一段ws的code.

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">

    <link rel="stylesheet" href="/assetPath/css/bootstrap.min.css">
    <link rel="stylesheet" href="/assetPath/css/bootstrap-grid.min.css">
    <link rel="stylesheet" href="/assetPath/css/bootstrap-reboot.min.css">
    <script rel="script" src="/assetPath/js/bootstrap.bundle.js"></script>

    <title>Gin Hello</title>
    <script>
        var ws = new WebSocket("ws://localhost:8080/ping");  
        // onOpen被觸發時, 去嘗試連線 
        ws.onopen = function(evt) {  
            console.log("Connection open ...");  
            ws.send("Hello WebSockets!");  
        };  
        // onMessage被觸發時, 來接收ws server傳來的訊息  
        ws.onmessage = function(evt) {  
            console.log("Received Message: " + evt.data);  
        };  
        // 由ws server發出的onClose事件  
        ws.onclose = function(evt) {  
            console.log("Connection closed.");  
        };  
        // 每秒發出一個現在時間的訊息
        var timeInterval = setInterval(() => ws.send(Date.now()), 1000)
</script>
</head>
<body>
<h1>Hi {{.title}}</h1>
</body>
</html>

註冊路由SetupRouter.go

pingRouting := router.Group("/ping")
{
    pingRouting.GET("", handler.WsPing)
}

來跑看看

這裡出現前面提的 upgrade請求
Sec-WebSocket-Key: xSNxfnX2LO/xX6wzzSwQ2Q==
來執行看看前面的計算sec-ws-accept的程式

一模一樣XD, 成功切到ws後, 就會發出ping, server就會回pong.
就建立好了ws長連線.

綠色箭頭表示ws.send()
紅色箭頭表示ws.onmessage()
可以看到server有收到, 且正常的推送訊息過來.

WebSocket能傳送的訊息類型還蠻多的, 除了剛剛看到的
Ping, Pong, Close, Text, 還有Binary.

// The message types are defined in RFC 6455, section 11.8.
const (
	// TextMessage denotes a text data message. The text message payload is
	// interpreted as UTF-8 encoded text data.
	TextMessage = 1

	// BinaryMessage denotes a binary data message.
	BinaryMessage = 2

	// CloseMessage denotes a close control message. The optional message
	// payload contains a numeric code and text. Use the FormatCloseMessage
	// function to format a close message payload.
	CloseMessage = 8

	// PingMessage denotes a ping control message. The optional message payload
	// is UTF-8 encoded text.
	PingMessage = 9

	// PongMessage denotes a pong control message. The optional message payload
	// is UTF-8 encoded text.
	PongMessage = 10
)

如果要作廣播或者是分房, 就要自己實做Hub了.

官方範例連結

WebSocket每條連線基本上都是吃記憶體的.
所以要單機撐起百萬條連線, 還是得要有足夠記憶體.
且OS要有些設定 像是fs.file-max, soft limit跟hard limit等的調校 等等的.

單元測試

為了方便測試, 改寫一下websocketHandler.go,
單獨把讀檔跟處理的部份拉出來, 因為我們就只要測試這個的行為.

package handler

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/websocket"
)

var upGrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}

func ProcessWs(ws *websocket.Conn) {
	for {
		// 讀取ws Socket傳來的訊息
		mt, message, err := ws.ReadMessage()
		if err != nil {
			break
		}
		// 如果是ping
		if string(message) == "ping" {
			// 就回pong
			message = []byte("pong")
		} else {
			// 如果是其他, 就回文字訊息類型, 內容就是回聲 (鸚鵡XD)
			ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprint("got it : "+string(message))))
		}
		// 寫入Websocket
		err = ws.WriteMessage(mt, message)
		if err != nil {
			break
		}
	}
}

func WsPing(ctx *gin.Context) {

	ws, err := upGrader.Upgrade(ctx.Writer, ctx.Request, nil)
	if err != nil {
		return
	}
	defer ws.Close()
	ProcessWs(ws)
}

websocketRouter_test.go

package test

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gorilla/websocket"
	"github.com/tedmax100/gin-angular/handler"
)

var upgrader = websocket.Upgrader{}

func echo(w http.ResponseWriter, r *http.Request) {

	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}
	defer c.Close()
	handler.ProcessWs(c)
}

func TestExample(t *testing.T) {
	// 建立一個測試server
	s := httptest.NewServer(http.HandlerFunc(echo))
	defer s.Close()

	// http://127.0.0.1 to ws://127.0.0.
	u := "ws" + strings.TrimPrefix(s.URL, "http")

	// 嘗試連線
	ws, _, err := websocket.DefaultDialer.Dial(u, nil)
	if err != nil {
		t.Fatalf("%v", err)
	}
	defer ws.Close()

	
    // 連線成功, 發送訊息並且接收後驗證
	if err := ws.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
		t.Fatalf("%v", err)
	}
	_, p, err := ws.ReadMessage()
	if err != nil {
		t.Fatalf("%v", err)
	}
	result := string(p)
	if result != "got it : hello" {
		t.Fatalf(result)
	}
}

簡單的WebSocket就玩到這, 但我還是覺得node的socket.io方便很多XD.
Go好像比較多服務都是走gRPC了. 改天玩看看


上一篇
Gin框架 檔案上傳 & 資料綁定和驗證
下一篇
Go gRPC第一次接觸...
系列文
下班加減學點Golang與Docker30

尚未有邦友留言

立即登入留言