iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 22
0
Modern Web

Go into Web!系列 第 22

Day22 | 結合 Redis 實作隨機一對一匿名聊天室

在昨天講完基本的 Redis 操作後,今天就讓我們修改 Day20 所做的公開匿名聊天室,結合 Redis 製作 隨機一對一匿名聊天室 吧!

設計概念

整體的概念簡單來說會分成幾個部分

給予不同的 session 一個隨機 id

根據連線進來的 session 我們會給予隨機的 uuid,這個 id 用於後續的配對與收發訊息用。

等待 list

一開始連線進來時會查詢 wait 這個 keylist 的第一筆資料,如果有值就進行配對,反之就是自己進入等待列表。

配對

聊天雙方的 key 互相成為對方的 value,例如 key 為 A 與 B,則 A 的 value 變成 B,B 的 value 變成 A。

離開聊天室

只要有一方離開聊天室時,雙方的配對的 Key 就會取消,這個時候要回到第二步重新等待。

實作

透過 session 取得 id

安裝 package

首先我們要先透過 session 取得作為識別的 uuid,因此要先透過 go get 的方式安裝 package。

go get github.com/google/uuid

初始化

接著我們寫一個方法,參數為 melodysession,透過此方法我們可以將 session 中的 chat_id 設定成隨機產生的 uuid

const KEY = "chat_id"
func InitSession(s *melody.Session) string {
	id := uuid.New().String()
	s.Set(KEY, id)
	return id
}

取得 session id

接著我們透過 Get 的方式取得相關的 session id,如果沒有的話就進行初始化。

func GetSessionID(s *melody.Session) string {
	if id, isExist := s.Get(KEY); isExist {
		return id.(string)
	}
	return InitSession(s)
}

排隊機制

接著我們開始實作排隊的機制,這邊將方法分開寫。

取得隊伍第一筆資料

首先可以透過查詢 wait list 的第一筆資料。

func GetWaitFirstKey() (string, error) {
	return redisClient.LPop(context.Background(), WAIT).Result()
}

將 id 加入到隊伍尾端

透過 LPush 的方法將 id 放到 list 的尾端。

func AddToWaitList(id string) error {
	return redisClient.LPush(context.Background(), WAIT, id).Err()
}

配對與移除

配對的部分主要是要將配對的兩個 id 進行綁定,移除的部分主要是要將兩個 id 從 redis 中刪除。

建立聊天室

將兩個 id 進行配對。

func CreateChat(id1, id2 string) {
	redisClient.Set(context.Background(), id1, id2, 0)
	redisClient.Set(context.Background(), id2, id1, 0)
}

移除聊天室

刪除兩個 id 的 key

func RemoveChat(id1, id2 string) {
	redisClient.Del(context.Background(), id1, id2)
}

修改 WebSocket Connect 方法

在 WebSocket 連線建立時,首先做初始化的動作,接著透過上面寫的 GetWaitFirstKey 方法查詢是否有等待聊天的 key,如果有就進行配對並且發送訊息通知,反之將自己加入等待列表。

m.HandleConnect(func(session *melody.Session) {
    id := InitSession(session)
    if key, err := GetWaitFirstKey(); err == nil && key != "" {
        CreateChat(id, key)
        msg := NewMessage("other", "對方已經", "加入聊天室").GetByteMessage()
        m.BroadcastFilter(msg, func(session *melody.Session) bool {
            compareID, _ := session.Get(KEY)
            return compareID == id || compareID == key
        })
    } else {
        AddToWaitList(id)
    }
})

修改訊息發送

雙方都加入聊天室後,透過剛才綁定後的結果查詢要發送訊息給誰,之後透過 BroadcastFilter 過濾接收對象。

m.HandleMessage(func(s *melody.Session, msg []byte) {
    id := GetSessionID(s)
    chatTo, _ := redisClient.Get(context.TODO(), id).Result()
    m.BroadcastFilter(msg, func(session *melody.Session) bool {
        compareID, _ := session.Get(KEY)
        return compareID == chatTo || compareID == id
    })
})

修改關閉連線方法

當其中一方關閉連線時代表聊天室已中斷,此時透過上面寫的 RemoveChat 方法將雙方的關係進行解除並通知對方。

m.HandleClose(func(session *melody.Session, i int, s string) error {
    id := GetSessionID(session)
    chatTo, _ := redisClient.Get(context.TODO(), id).Result()
    msg := NewMessage("other", "對方已經", "離開聊天室").GetByteMessage()
    RemoveChat(id, chatTo)
    return m.BroadcastFilter(msg, func(session *melody.Session) bool {
        compareID, _ := session.Get(KEY)
        return compareID == chatTo
    })
})

程式總結

修改後的程式如下

package main

import (
	"context"
	"encoding/json"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
	"github.com/google/uuid"
	"gopkg.in/olahol/melody.v1"
	"log"
	"net/http"
)

type Message struct {
	Event   string `json:"event"`
	Name    string `json:"name"`
	Content string `json:"content"`
}

const (
	KEY  = "chat_id"
	WAIT = "wait"
)

func NewMessage(event, name, content string) *Message {
	return &Message{
		Event:   event,
		Name:    name,
		Content: content,
	}
}

func (m *Message) GetByteMessage() []byte {
	result, _ := json.Marshal(m)
	return result
}

var redisClient *redis.Client

func init() {
	redisClient = redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "a12345", // no password set
		DB:       0,        // use default DB
	})
	pong, err := redisClient.Ping(context.Background()).Result()
	if err == nil {
		log.Println("redis 回應成功,", pong)
	} else {
		log.Fatal("redis 無法連線,錯誤為", err)
	}
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("template/html/*")
	r.Static("/assets", "./template/assets")
	r.GET("/", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", nil)
	})

	m := melody.New()
	r.GET("/ws", func(c *gin.Context) {
		m.HandleRequest(c.Writer, c.Request)
	})

	m.HandleMessage(func(s *melody.Session, msg []byte) {
		id := GetSessionID(s)
		chatTo, _ := redisClient.Get(context.TODO(), id).Result()
		m.BroadcastFilter(msg, func(session *melody.Session) bool {
			compareID, _ := session.Get(KEY)
			return compareID == chatTo || compareID == id
		})
	})

	m.HandleConnect(func(session *melody.Session) {
		id := InitSession(session)
		if key, err := GetWaitFirstKey(); err == nil && key != "" {
			CreateChat(id, key)
			msg := NewMessage("other", "對方已經", "加入聊天室").GetByteMessage()
			m.BroadcastFilter(msg, func(session *melody.Session) bool {
				compareID, _ := session.Get(KEY)
				return compareID == id || compareID == key
			})
		} else {
			AddToWaitList(id)
		}
	})

	m.HandleClose(func(session *melody.Session, i int, s string) error {
		id := GetSessionID(session)
		chatTo, _ := redisClient.Get(context.TODO(), id).Result()
		msg := NewMessage("other", "對方已經", "離開聊天室").GetByteMessage()
		RemoveChat(id, chatTo)
		return m.BroadcastFilter(msg, func(session *melody.Session) bool {
			compareID, _ := session.Get(KEY)
			return compareID == chatTo
		})
	})
	r.Run(":5000")
}

func AddToWaitList(id string) error {
	return redisClient.LPush(context.Background(), WAIT, id).Err()
}

func GetWaitFirstKey() (string, error) {
	return redisClient.LPop(context.Background(), WAIT).Result()
}

func CreateChat(id1, id2 string) {
	redisClient.Set(context.Background(), id1, id2, 0)
	redisClient.Set(context.Background(), id2, id1, 0)
}

func RemoveChat(id1, id2 string) {
	redisClient.Del(context.Background(), id1, id2)
}
func GetSessionID(s *melody.Session) string {
	if id, isExist := s.Get(KEY); isExist {
		return id.(string)
	}
	return InitSession(s)
}

func InitSession(s *melody.Session) string {
	id := uuid.New().String()
	s.Set(KEY, id)
	return id
}

測試

連線測試

首先開啟三個分頁並且輸入 http://127.0.0.1:5000 後,可以看到第一與第二分頁呈現這樣的狀態。

聊天測試

接著可以在第一與第二分頁分別輸入訊息測試,兩邊可以互相接收訊息,而第三個分頁完全沒有動靜。

後續加入聊天測試

開啟第四個分頁輸入 http://127.0.0.1:5000,可以看到第三個分頁也會顯示 對方已經 加入聊天室

小結

這次結合 Redis 所製作的隨機一對一匿名聊天室範例非常的簡單,但如果流量大一點必須要結合所謂的 lock 機制才可以避免 race condition 的情況發生,後續有機會再跟各位分享!


上一篇
Day21 | 淺談 redis
下一篇
Day 23 | 自己測一下程式好嗎?淺入單元測試(一)
系列文
Go into Web!30

尚未有邦友留言

立即登入留言