使用 Go 和 ReactJS 構建聊天系統(四):處理多個客戶端

SmauelL發表於2020-01-19

是時候實現處理多個客戶端並向每個連線的客戶端廣播任何收到的訊息的功能了。本節結束後,我們將:

  • 實現 Pool 機制,該機制將有效地使我們能夠跟蹤到 WebSocket 伺服器中的連線數。
  • 我們還可以將任何收到的訊息廣播到連線池中的所有連線。
  • 當另一個客戶端連線或斷開連線時,我們還可以通知現有所有的客戶端。

在本節完成後,我們的應用程式將像下面一樣:

Chat Application Screenshot

既然我們已經完成了必要的內部清理工作,那麼我們可以繼續改進程式碼庫。我們將把一些應用程式分成多個子包,以便於開發。

現在,理想情況下,您的 main.go 檔案應該只是 Go 應用程式的入口點,應該相當小,並可以呼叫專案中的其他軟體包。

注意: 我們將基於非官方的 Go 專案標準來進行專案佈局 —— golang-standards/project-layout

讓我們在 backend 目錄中建立一個名為 pkg / 的新目錄。在其中,我們將要建立另一個名為 websocket / 的目錄,其中將包含一個 websocket.go 檔案。

我們將把目前在 main.go 檔案中擁有的許多 WebSocket 特定程式碼移至新的 websocket.go 檔案中。

注意: 不過要注意的一件事是,當我們複製函式時,我們需要將要在專案的其餘部分中顯示的每個函式的首字母大寫。

package websocket

import (
    "fmt"
    "io"
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

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

func Upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
    ws, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return ws, err
    }
    return ws, nil
}

func Reader(conn *websocket.Conn) {
    for {
        messageType, p, err := conn.ReadMessage()
        if err != nil {
            log.Println(err)
            return
        }

        fmt.Println(string(p))

        if err := conn.WriteMessage(messageType, p); err != nil {
            log.Println(err)
            return
        }
    }
}

func Writer(conn *websocket.Conn) {
    for {
        fmt.Println("Sending")
        messageType, r, err := conn.NextReader()
        if err != nil {
            fmt.Println(err)
            return
        }
        w, err := conn.NextWriter(messageType)
        if err != nil {
            fmt.Println(err)
            return
        }
        if _, err := io.Copy(w, r); err != nil {
            fmt.Println(err)
            return
        }
        if err := w.Close(); err != nil {
            fmt.Println(err)
            return
        }
    }
}

現在我們已經建立了這個新的 websocket 包,然後我們想更新我們的 main.go 檔案以調出這個包。我們首先必須在檔案頂部的匯入列表中新增一個新匯入,然後我們可以通過在呼叫之前新增 websocket. 來呼叫該包中的函式:

package main

import (
    "fmt"
    "net/http"

    "github.com/TutorialEdge/realtime-chat-go-react/pkg/websocket"
)

func serveWs(w http.ResponseWriter, r *http.Request) {
    ws, err := websocket.Upgrade(w, r)
    if err != nil {
        fmt.Fprintf(w, "%+V\n", err)
    }
    go websocket.Writer(ws)
    websocket.Reader(ws)
}

func setupRoutes() {
    http.HandleFunc("/ws", serveWs)
}

func main() {
    fmt.Println("Distributed Chat App v0.01")
    setupRoutes()
    http.ListenAndServe(":8080", nil)
}

進行了這些新的更改後,我們應該檢查一下自己所做的工作並未破壞我們現有的功能。嘗試再次執行後端和前端,並確保仍然可以傳送和接收訊息:

$ go run main.go

如果成功,我們可以繼續擴充套件程式碼庫以處理多個客戶端。

至此,您的目錄結構應如下所示:

- backend/
- - pkg/
- - - websocket/
- - - - websocket.go
- - main.go
- - go.mod
- - go.sum
- frontend/
- ...

太好了,現在我們已經完成了基本的整理工作,可以繼續改進後端並實施一種機制來處理多個客戶。

為此,我們需要考慮如何處理與 WebSocket 伺服器的連線。 每當建立新連線時,我們都必須將它們新增到現有連線池中,並確保每次傳送訊息時,該池中的每個人都會收到該訊息。

使用通道

我們將在具有大量併發連線的系統上進行工作。對於每個併發連線,都會在連線期間啟動一個新的 goroutine。這意味著我們必須擔心這些併發的 goroutines 之間的通訊,並確保我們所做的一切都是執行緒安全的。

這意味著,當我們進一步實現 Pool 結構時,我們必須考慮使用 sync.Mutexgoroutines 互斥以同時訪問/修改我們的資料,或者我們可以使用 channels

對於這個專案,我認為我們最好使用 channels 並使用它們在多個併發的 goroutines 之間以安全的方式進行通訊。

注意: 如果您想了解有關 Go 頻道的更多資訊,可以在這裡檢視我的其他文章: Go 頻道指南

Client.go

首先建立一個名為 Client.go 的新檔案,該檔案將位於我們的 pkg / websocket 目錄中,並在其中定義一個 Client 結構,其中包含以下內容:

  • ID —— 特定連線的唯一可識別字串
  • Conn —— 指向 websocket.Conn 物件的指標
  • Pool —— 指向該客戶端將參與其中的 Pool 的指標

我們還將定義一個 Read() 方法,該方法將不斷偵聽此 Client 的 WebSocket 連線上通過的新訊息。

如果有任何訊息,它將把這些訊息傳遞到 Pool 的 Broadcast 頻道,該通道隨後將接收到的訊息廣播到池中的每個客戶端。

package websocket

import (
    "fmt"
    "log"
    "sync"

    "github.com/gorilla/websocket"
)

type Client struct {
    ID   string
    Conn *websocket.Conn
    Pool *Pool
}

type Message struct {
    Type int    `json:"type"`
    Body string `json:"body"`
}

func (c *Client) Read() {
    defer func() {
        c.Pool.Unregister <- c
        c.Conn.Close()
    }()

    for {
        messageType, p, err := c.Conn.ReadMessage()
        if err != nil {
            log.Println(err)
            return
        }
        message := Message{Type: messageType, Body: string(p)}
        c.Pool.Broadcast <- message
        fmt.Printf("Message Received: %+v\n", message)
    }
}

太好了,既然我們已經在程式碼中定義了客戶,那麼我們就可以繼續實施我們的 Pool 了。

Pool 結構

我們將在與 client.go 檔案和 websocket.go 檔案同一目錄下建立 pool.go 檔案。

讓我們從定義一個 Pool 結構開始,該結構將包含併發通訊所需的所有 channels 以及客戶端的 map

package websocket

import "fmt"

type Pool struct {
    Register   chan *Client
    Unregister chan *Client
    Clients    map[*Client]bool
    Broadcast  chan Message
}

func NewPool() *Pool {
    return &Pool{
        Register:   make(chan *Client),
        Unregister: make(chan *Client),
        Clients:    make(map[*Client]bool),
        Broadcast:  make(chan Message),
    }
}

我們需要確保應用程式中只有一個點能夠寫入 WebSocket 連線,否則我們將面臨併發寫入問題。因此,讓我們定義 Start() 方法,該方法將不斷監聽傳遞給我們 Pool 中任何一個通道的內容,然後,如果其中任何一個通道收到了任何內容,它將採取相應的措施。

  • 註冊 —— 當有新客戶端連線時,我們的註冊頻道將向此 Pool 中的所有客戶端傳送 New User Joined...
  • 取消註冊 —— 將取消註冊使用者,並在客戶端斷開連線時通知 Pool。
  • 客戶端 —— 客戶到布林值的對映。 我們可以使用布林值來指示活動/非活動狀態,但不能根據瀏覽器的焦點進一步斷開連線。
  • 廣播 —— 一個通道,該通道在傳遞訊息時將迴圈通過 Pool 中的所有客戶端,並通過套接字連線傳送訊息。

現在讓我們編寫程式碼:

func (pool *Pool) Start() {
    for {
        select {
        case client := <-pool.Register:
            pool.Clients[client] = true
            fmt.Println("Size of Connection Pool: ", len(pool.Clients))
            for client, _ := range pool.Clients {
                fmt.Println(client)
                client.Conn.WriteJSON(Message{Type: 1, Body: "New User Joined..."})
            }
            break
        case client := <-pool.Unregister:
            delete(pool.Clients, client)
            fmt.Println("Size of Connection Pool: ", len(pool.Clients))
            for client, _ := range pool.Clients {
                client.Conn.WriteJSON(Message{Type: 1, Body: "User Disconnected..."})
            }
            break
        case message := <-pool.Broadcast:
            fmt.Println("Sending message to all clients in Pool")
            for client, _ := range pool.Clients {
                if err := client.Conn.WriteJSON(message); err != nil {
                    fmt.Println(err)
                    return
                }
            }
        }
    }
}

Websocket.go

太棒了,讓我們對 websocket.go 檔案進行一些更小的更改,並刪除一些不再需要的功能和方法:

package websocket

import (
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

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

func Upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return nil, err
    }

    return conn, nil
}

最後,我們需要更新我們的 main.go 檔案以在每個連線上建立一個新的 Client 並將該客戶端註冊到 Pool 中:

package main

import (
    "fmt"
    "net/http"

    "github.com/TutorialEdge/realtime-chat-go-react/pkg/websocket"
)

func serveWs(pool *websocket.Pool, w http.ResponseWriter, r *http.Request) {
    fmt.Println("WebSocket Endpoint Hit")
    conn, err := websocket.Upgrade(w, r)
    if err != nil {
        fmt.Fprintf(w, "%+v\n", err)
    }

    client := &websocket.Client{
        Conn: conn,
        Pool: pool,
    }

    pool.Register <- client
    client.Read()
}

func setupRoutes() {
    pool := websocket.NewPool()
    go pool.Start()

    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        serveWs(pool, w, r)
    })
}

func main() {
    fmt.Println("Distributed Chat App v0.01")
    setupRoutes()
    http.ListenAndServe(":8080", nil)
}

現在,我們已經進行了所有必要的更改,我們應該在一個合適的位置來測試我們所做的事情,並確保一切都按預期進行。

啟動您的後端應用程式:

$ go run main.go
Distributed Chat App v0.01

如果您在幾個瀏覽器標籤中開啟 http://localhost:3000/,您應該注意到這些標籤會自動連線到我們的後端 WebSocket 伺服器,我們現在可以傳送和接收來自同一 Pool 中連線的其他客戶端的訊息!

Chat Application Screenshot

因此,在本系列的這一部分中,我們設法實現了一種處理多個客戶端並向連線 Pool 中連線的每個人廣播訊息的方法。

現在事情開始變得更加有趣了。我們可以在下節中開始新增一些很酷的新功能,例如自定義訊息。

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

最初的時候也是最苦的時候,最苦的時候也是最酷的時候。

相關文章