如何在Go語言中使用Websockets:最佳工具與行動指南

2019-10-19   Go語言中文網

如今,在不刷新頁面的情況下發送消息並獲得即時響應在我們看來是理所當然的事情。但是曾幾何時,啟用實時功能對開發人員來說是一個真正的挑戰。開發社區在HTTP長輪詢(http long polling)和AJAX上走了很長一段路,但終於還是找到了一種構建真正的實時應用程式的解決方案。

該解決方案以WebSockets的形式出現,這使得在用戶瀏覽器和伺服器之間開啟一個交互式會話成為可能。WebSocket支持瀏覽器將消息發送到伺服器並接收事件驅動的響應,而不必使用長輪詢伺服器的方式去獲取響應。

就目前而言,WebSockets是構建實時應用程式的首選解決方案,包括在線遊戲,即時通訊程序,跟蹤應用程式等均在使用這一方案。本文將說明WebSockets的操作方式,並說明我們如何使用

Go語言

構建WebSocket應用程式。我們還將比較最受歡迎的WebSocket庫,以便您可以根據選擇出最適合您的那個。

網絡套接字(network socket)與WebSocket

在Go中使用WebSockets之前,讓我們在網絡套接字和WebSockets之間劃清一條界限。

網絡套接字

網絡套接字(或簡稱為套接字)充當內部端點,用於在同一計算機或同一網絡上的不同計算機上運行的應用程式之間交換數據。

套接字是Unix和Windows作業系統的關鍵部分,它們使開發人員更容易創建支持網絡的軟體。應用程式開發人員不可以直接在程序中包含套接字,而不是從頭開始構建網絡連接。由於網絡套接字可用於許多不同的網絡協議(如HTTP,FTP等),因此可以同時使用多個套接字。

套接字是通過一組函數調用創建和使用的,這些函數調用有時稱為套接字的應用程式編程接口(API)。正是由於這些函數調用,套接字可以像常規文件一樣被打開。

網絡套接字有如下幾種類型:

  • 數據報套接字(SOCK_DGRAM),也稱為無連接套接字,使用用戶數據報協議(UDP)。數據報套接字支持雙向消息流並保留記錄邊界。
  • 流套接字(SOCK_STREAM),也稱為面向連接的套接字,使用傳輸控制協議(TCP),流控制傳輸協議(SCTP)或數據報擁塞控制協議(DCCP)。這些套接字提供了沒有記錄邊界的雙向,可靠,有序且無重複的數據流。
  • 原始套接字(或原始IP套接字)通常在路由器和其他網絡設備中可用。這些套接字通常是面向數據報的,儘管它們的確切特性取決於協議提供的接口。大多數應用程式不使用原始套接字。提供它們是為了支持新的通信協議的開發,並提供對現有協議更深層設施的訪問。

套接字通信

首先,讓我們弄清楚如何確保每個套接字都是唯一的。否則,您將無法建立可靠的溝通通道(channel)。

為每個進程(process)提供唯一的PID有助於解決本地問題。但是,這種方法不適用於網絡。要創建唯一的套接字,我們建議使用TCP / IP協議。使用TCP / IP,網絡層的IP位址在給定網絡內是唯一的,並且協議和埠在主機應用程式之間是唯一的。

TCP和UDP是用於主機之間通信的兩個主要協議。讓我們看看您的應用程式如何連接到TCP和UDP套接字。

  • 連接到TCP套接字

為了建立TCP連接,Go客戶端使用net程序包中的DialTCP函數。DialTCP返回一個TCPConn對象。建立連接後,客戶端和伺服器開始交換數據:客戶端通過TCPConn向伺服器發送請求,伺服器解析請求並發送響應,TCPConn從伺服器接收響應。

圖:TCP Socket

該連接將持續保持有效,直到客戶端或伺服器將其關閉。創建連接的函數如下:

客戶端:

// init
tcpAddr, err := net.ResolveTCPAddr(resolver, serverAddr)
if err != nil {
// handle error
}
conn, err := net.DialTCP(network, nil, tcpAddr)
if err != nil {
// handle error
}
// send message
_, err = conn.Write({message})
if err != nil {
// handle error
}
// receive message
var buf [{buffSize}]byte
_, err := conn.Read(buf[0:])
if err != nil {
// handle error
}

服務端:

// init
tcpAddr, err := net.ResolveTCPAddr(resolver, serverAddr)
if err != nil {
// handle error
}
listener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
// handle error
}
// listen for an incoming connection
conn, err := listener.Accept()
if err != nil {
// handle error
}
// send message
if _, err := conn.Write({message}); err != nil {
// handle error
}
// receive message
buf := make([]byte, 512)
n, err := conn.Read(buf[0:])
if err != nil {
// handle error
}
  • 連接到UDP套接字

與TCP套接字相反,使用UDP套接字,客戶端只是向伺服器發送數據報。沒有Accept函數,因為伺服器不需要接受連接,而只是等待數據報到達。

圖:UDP Socket

其他TCP函數都具有UDP對應的函數;只需在上述函數中將TCP替換為UDP。

客戶端:

// init
raddr, err := net.ResolveUDPAddr("udp", address)
if err != nil {
// handle error
}
conn, err := net.DialUDP("udp", nil, raddr)
if err != nil {
// handle error
}
.......
// send message
buffer := make([]byte, maxBufferSize)
n, addr, err := conn.ReadFrom(buffer)
if err != nil {
// handle error
}
.......
// receive message
buffer := make([]byte, maxBufferSize)
n, err = conn.WriteTo(buffer[:n], addr)
if err != nil {
// handle error
}

服務端:

// init
udpAddr, err := net.ResolveUDPAddr(resolver, serverAddr)
if err != nil {
// handle error
}
conn, err := net.ListenUDP("udp", udpAddr)
if err != nil {
// handle error
}
.......
// send message
buffer := make([]byte, maxBufferSize)
n, addr, err := conn.ReadFromUDP(buffer)
if err != nil {
// handle error
}
.......
// receive message
buffer := make([]byte, maxBufferSize)
n, err = conn.WriteToUDP(buffer[:n], addr)
if err != nil {
// handle error
}

什麼是WebSocket

WebSocket通信協議通過單個TCP連接提供全雙工通信通道。與HTTP相比,WebSocket不需要您發送請求即可獲得響應。它們允許雙向數據流,因此您只需等待伺服器響應即可。可用時,它將向您發送一條消息。

對於需要連續數據交換的服務(例如即時通訊程序,在線遊戲和實時交易系統),WebSockets是一個很好的解決方案。您可以在RFC 6455規範中找到有關WebSocket協議的完整信息。

WebSocket連接由瀏覽器請求發起,並由伺服器響應,之後連接就建立起來了。此過程通常稱為握手。WebSockets中的特殊標頭僅需要瀏覽器與伺服器之間的一次握手即可建立連接,該連接將在其整個生命周期內保持活動狀態。

WebSockets解決了許多實時Web開發的難題,與傳統的HTTP相比,它具有許多優點:

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

圖:WebSocket

WebSocket協議實現起來相對簡單。它使用HTTP協議進行初始握手。成功握手後,連接就建立起來了,並且WebSocket實質上使用原始TCP(raw 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應用

要基於該net/http 庫編寫簡單的WebSocket echo伺服器,您需要:

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

首先,讓我們創建一個帶有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劫持(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"))
.....
}

「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庫列表

有幾個第三方庫可簡化開發人員的開發工作,並極大地促進使用WebSockets。

  • STDLIB(golang.org/x/net/websocket)

此WebSocket庫是標準庫的一部分。如RFC 6455規範中所述,它為WebSocket協議實現了客戶端和伺服器。它不需要安裝並且有很好的官方文檔。但是,另一方面,它仍然缺少其他WebSocket庫中可以找到的某些功能。/x/net/websocket軟體包中的Golang WebSocket實現不允許用戶以明確的方式重用連接之間的I/O緩衝區。

讓我們檢查一下STDLIB軟體包的工作方式。這是用於執行基本功能(如創建連接以及發送和接收消息)的代碼示例。

首先,要安裝和使用此庫,應將以下代碼行添加到您的:

import "golang.org/x/net/websocket"

客戶端:

 // create connection
// schema can be ws:// or wss://
// host, port – WebSocket server
conn, err := websocket.Dial("{schema}://{host}:{port}", "", op.Origin)
if err != nil {
// handle error
}
defer conn.Close()
.......
// send message
if err = websocket.JSON.Send(conn, {message}); err != nil {
// handle error
}
.......
// receive message
// messageType initializes some type of message
message := messageType{}
if err := websocket.JSON.Receive(conn, &message); err != nil {
// handle error
}
.......

伺服器端:

 // Initialize WebSocket handler + server
mux := http.NewServeMux()
mux.Handle("/", websocket.Handler(func(conn *websocket.Conn) {
func() {
for {
// do something, receive, send, etc.
}
}
.......
// receive message
// messageType initializes some type of message
message := messageType{}
if err := websocket.JSON.Receive(conn, &message); err != nil {
// handle error
}
.......
// send message
if err := websocket.JSON.Send(conn, message); err != nil {
// handle error
}
........
  • GORILLA

Gorilla Web工具包中的WebSocket軟體包擁有WebSocket協議的完整且經過測試的實現以及穩定的軟體包API。WebSocket軟體包文檔齊全,易於使用。您可以在Gorilla官方網站上找到文檔。

安裝

go get github.com/gorilla/websocket
Examples of code
Client side:
// init
// schema – can be ws:// or wss://
// host, port – WebSocket server
u := url.URL{
Scheme: {schema},
Host: {host}:{port},
Path: "/",
}
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
// handle error
}
.......
// send message
err := c.WriteMessage(websocket.TextMessage, {message})
if err != nil {
// handle error
}
.......
// receive message
_, message, err := c.ReadMessage()
if err != nil {
// handle error
}
.......

伺服器端:

 // init
u := websocket.Upgrader{}
c, err := u.Upgrade(w, r, nil)
if err != nil {
// handle error
}
.......
// receive message
messageType, message, err := c.ReadMessage()
if err != nil {
// handle error
}
.......
// send message
err = c.WriteMessage(messageType, {message})
if err != nil {
// handle error
}
.......
  • GOBWAS

這個微小的WebSocket封裝具有強大的功能列表,例如零拷貝升級(zero-copy upgrade)和允許構建自定義數據包處理邏輯的低級API。GOBWAS在I/O期間不需要中間做額外分配操作。它還在wsutil軟體包中提供了圍繞API的高級包裝API和幫助API,使開發人員可以快速使用,而無需深入研究協議的內部。該庫具有靈活的API,但這是以可用性和清晰度為代價的。

可在GoDoc網站上找到文檔。您可以通過下面代碼行來安裝它:

go get github.com/gobwas/ws

客戶端:

 // init
// schema – can be ws or wss
// host, port – ws server
conn, _, _, err := ws.DefaultDialer.Dial(ctx, {schema}://{host}:{port})
if err != nil {
// handle error
}
.......
// send message
err = wsutil.WriteClientMessage(conn, ws.OpText, {message})
if err != nil {
// handle error
}
.......
// receive message
msg, _, err := wsutil.ReadServerData(conn)
if err != nil {
// handle error
}
.......

伺服器端:

 // init
listener, err := net.Listen("tcp", op.Port)
if err != nil {
// handle error
}
conn, err := listener.Accept()
if err != nil {
// handle error
}
upgrader := ws.Upgrader{}
if _, err = upgrader.Upgrade(conn); err != nil {
// handle error
}
.......
// receive message
for {
reader := wsutil.NewReader(conn, ws.StateServerSide)
_, err := reader.NextFrame()
if err != nil {
// handle error
}
data, err := ioutil.ReadAll(reader)
if err != nil {
// handle error
}
.......
}
.......
// send message
msg := "new server message"
if err := wsutil.WriteServerText(conn, {message}); err != nil {
// handle error
}
.......
  • GOWebsockets

該工具提供了廣泛的易於使用的功能。它允許並發控制,數據壓縮和設置請求標頭。GoWebsockets支持代理和子協議,用於發送和接收文本和二進位數據。開發人員還可以啟用或禁用SSL驗證。

您可以在GoDoc網站和項目的GitHub頁面上找到有關如何使用GOWebsockets的文檔和示例。通過添加以下代碼行來安裝軟體包:

go get github.com/sacOO7/gowebsocket

客戶端:

 // init
// schema – can be ws or wss
// host, port – ws server
socket := gowebsocket.New({schema}://{host}:{port})
socket.Connect()
.......
// send message
socket.SendText({message})
or
socket.SendBinary({message})
.......
// receive message
socket.OnTextMessage = func(message string, socket gowebsocket.Socket) {
// hande received message
};
or
socket.OnBinaryMessage = func(data [] byte, socket gowebsocket.Socket) {
// hande received message
};
.......

伺服器端:

 // init
// schema – can be ws or wss
// host, port – ws server
conn, _, _, err := ws.DefaultDialer.Dial(ctx, {schema}://{host}:{port})
if err != nil {
// handle error
}
.......
// send message
err = wsutil.WriteClientMessage(conn, ws.OpText, {message})
if err != nil {
// handle error
}
.......
// receive message
msg, _, err := wsutil.ReadServerData(conn)
if err != nil {
// handle error
}

比較現有解決方案

我們已經描述了Go中使用最廣泛的四個WebSocket庫。下表包含這些工具的詳細比較。

圖 Websocket庫比較

為了更好地分析其性能,我們還進行了一些基準測試。結果如下:

  • 如您所見,GOBWAS與其他庫相比具有明顯的優勢。每個操作分配的內存更少,每個分配使用的內存和時間更少。另外,它的I/O分配為零。此外,GOBWAS還具有創建WebSocket客戶端與伺服器的交互並接收消息片段所需的所有方法。您也可以使用它輕鬆地使用TCP套接字。
  • 如果您真的不喜歡GOBWAS,則可以使用Gorilla。它非常簡單,幾乎具有所有相同的功能。您也可以使用STDLIB,但由於它缺少許多必要的功能,並且在生產中表現不佳,而且正如您在基準測試中所看到的那樣,它的性能較弱。GOWebsocket與STDLIB大致相同。但是,如果您需要快速構建原型或MVP,則它可能是一個合理的選擇。

除了這些工具之外,還有幾種替代實現可讓您構建強大的流處理解決方案。其中有:

  • go-socket.io
  • Apache Thrift
  • gRPC
  • package rpc

流技術的不斷發展以及WebSockets等文檔較好的可用工具的存在,使開發人員可以輕鬆創建真正的實時應用程式。如果您需要使用WebSockets創建實時應用程式的建議或幫助,請給我們寫信。希望本教程對您有所幫助。

本文翻譯自《How to Use Websockets in Golang : Best Tools and Step-by-Step Guide》。

譯者:TonyBai