iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 22
0
Modern Web

Go into Web!系列 第 22 篇

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

  • 分享至 

  • xImage
  •  

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

設計概念

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

給予不同的 session 一個隨機 id

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

等待 list

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

配對

聊天雙方的 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

初始化

接著我們寫一個方法,參數為 melody 的 session,透過此方法我們可以將 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 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言