Contents

Chat system with Golang

前言

年前接了一個重構聊天系統到網頁上的任務。

現有的前端由於不是使用網頁,所以舊的 code 沒什麼參考價值。

後端的部分先前開發的 RD 已經不在公司了 orz,交接來的 RD 說花時間看舊的 code 不如寫一個新的,但他要忙別的專案沒空弄新的給我接。

Alright,後端說要寫新的,但他沒時間弄。我自己沒學過 golang,但我有的是時間,那麼身為 golang 新手,如果要弄出一個後端,邊學邊寫新的應該比直接看懂舊的來的簡單(吧?

就使用 gorilla 的 Websocket package 吧!

於是我風塵僕僕的踏進了 golang,初識了 goroutine & Channel ,並且學會一些打印出 golang 中各種資料型態的方法(相較於 js & ts,golang 這個強型態語言要從 0 開始直接理解現有的專案程式碼實在不是很容易…)。最後總算是看懂了 document 給的 example,並且延伸出了私人訊息的功能。

完成了這一切後跟老闆報告進度,老闆表示: 用舊的後端吧,現有的功能其實不只聊天,要讓我重新理解並完整重構……有點……。

well,老闆說的有理,改進比改革省時多了,而且歷經一個月的歷練,理解現有的 code 應該不再那麼難了。

但為了不要讓這曾經的努力漸漸淡忘在腦海中,還是將經歷過的紀錄下來吧!

Good to know first

  • channel type
  • make function
  • select for channel
  • Method/Function Receiver

gorilla example code

目標是將 example code 延伸出私訊的功能,所以打算先看懂現有的 code,在來開始做延伸。

main.go

首先從程式 entry point 開始看起。

func main() {
  flag.Parse()

  // create hub instance
  hub := newHub()

  // run new goroutine for hub
  go hub.run()

  // serve front end page, but it's not necessary for websocket server
  http.HandleFunc("/", serveHome)

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

  // start serve
  err := http.ListenAndServe(*addr, nil)
  if err != nil {
    log.Fatal("ListenAndServe: ", err)
  }
}

看起來我們所需要的 websocket 精華寫在 serveWs 以及 newHub() 中。

client.go

接著 follow 上述來到 client.go 找到 serveWs 這個 function。

func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
  // upgrade http request to websocket
  conn, err := upgrader.Upgrade(w, r, nil)
  if err != nil {
    log.Println(err)
    return
  }
  // declare a client variable with current connection data
  client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}

  // send client as a data in to register channel
  client.hub.register <- client

  // Allow collection of memory referenced by the caller by doing all work in
  // new goroutines.
  go client.writePump()
  go client.readPump()
}

資料流向的重點是 client.hub.register <- client

並且參考官網

Channels are a typed conduit through which you can send and receive values with the channel operator, <-.

ch <- v    // Send v to channel ch.
v := <-ch  // Receive from ch, and
           // assign value to v.

hub.go 裡面可以知道 Hub 中的 registerChannel type,因此這邊可以理解是 client 這筆 data 被傳送進了 client.hub.register 這個 Channel

hub.go

目前傳進 Channel 的 data 還沒有人接住,所以我們來到 hub.go 找到下段程式碼

// Method/Function Receiver: 'method' in golang is a 'function' with 'receiver'
// declare method 'run()' bind to '*Hub' by receiver '(h *Hub)'
func (h *Hub) run() {
  for {
    select {
    // receive message from h.register(channel type)
    case client := <-h.register:
      // update h.clients(add new client)
      h.clients[client] = true

    // client receive message from h.unregister(channel type)
    case client := <-h.unregister:
      if _, ok := h.clients[client]; ok {
      delete(h.clients, client)
      close(client.send)
    }

    // message from h.broadcast(channel type)
    case message := <-h.broadcast:
      // here the example sends message to all the clients who has already registered
      for client := range h.clients {
        select {
        // put message data in to client.send channel
        case client.send <- message:
        default:
          close(client.send)
          delete(h.clients, client)
        }
      }
    }
  }
}

注意到 case client := <-h.register: 其實就是將 register 這個 Channel 拿到的資料存進這裡的 client 變數中,然後再透過 h.clients[client] = true 來把 client 註冊進一開始在 main.go 中呼叫的 newHub() 這個 goroutine 的 clients 中,以此做到紀錄所有線上的 Clients。

如果到這邊有卡住的話可以參考上面的:

v := <-ch  // Receive from ch, and
           // assign value to v.

至此算是順利走完一趟有關 Channel 的 data flow 了。

readPump

接下來可以延續上面,看到 client.go 的最後兩行,發現 writePumpreadPump 都各自有一個 goroutine 在處理。

go client.writePump()
go client.readPump()

首先看接收訊息的 readPump ,可以發現他在對資料做了一頓 Set 之後,最後一行執行了 c.hub.broadcast <- message,這邊同上面邏輯,可以理解是將 message 這個 data 送進 c.hub.broadcast 這個 Channel 中。

再看到 hub.go 中的 run function,case message := <-h.broadcast: 就寫出了當 h.broadcast 接收到資料時所需要做的事情,這邊發現他是將收到的資料傳送給上面記錄的 clients 裡的所有 client。

private message

第一步,紀錄訊息資料

藉由上述我們可以知道派發訊息的 feature 是透過 readPump & case message := <-h.broadcast 兩段程式碼來完成的,因此這邊可以將原本的 readPump 改寫為

func (c *Client) readPump(from string, to string) {
  defer func() {
    c.hub.unregister <- c
    c.conn.Close()
  }()
  c.conn.SetReadLimit(maxMessageSize)
  c.conn.SetReadDeadline(time.Now().Add(pongWait))
  c.conn.SetPongHandler(func(string) error {c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
  for {
    _, message, err := c.conn.ReadMessage()
    if err != nil {
      if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
        log.Printf("error: %v", err)
      }
      break
    }
    message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))

    // add message time
    var messageMap map[string]string
    json.Unmarshal([]byte(string(message)), &messageMap)
    nowSec := time.Now().Unix()
    messageMap["time"] = strconv.FormatInt(nowSec*1000, 10)

    // add sender and receiver
    messageMap["from"] = from
    messageMap["to"] = to
    newMessage, marshalErr := json.Marshal(messageMap)

    if marshalErr != nil {
      log.Printf("error: %v", marshalErr)
    }

    // send to hub(case message := <-h.broadcast:)
    c.hub.broadcast <- newMessage
  }
}

這邊給 readPump 增加了 from & to 兩個參數來記住 message 是來自誰以及傳給誰,並且同時增加了 time field 來記錄伺服器傳送訊息的時間。

第二步,連接 ws 時紀錄 sender & receiver

在呼叫 readPumpserveWs 中增加接收"傳送訊息人( sender )“以及"接收訊息人( receiver )“的程式碼,如下。

senderId := r.URL.Query().Get("senderId")
receiverId := r.URL.Query().Get("receiverId")
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256), senderId: senderId}
// ---------------
go client.readPump(senderId, messageReceiver)

由於上面在 Client type 中新增了 senderId 這個 field,因此宣告 Client structure 的地方也要改為

type Client struct {
	hub *Hub

	// The websocket connection.
	conn *websocket.Conn

	// Buffered channel of outbound messages.
	send chan []byte

	// add senderId field
	senderId string
}

第三步,只把訊息傳給指定的 sender & receiver

接著來到 hub.go 裡的 case message := <-h.broadcast:

case message := <-h.broadcast:
  for client := range h.clients {
    // this part looks weird, may need golang pro to improve
    var messageMap map[string]string
    json.Unmarshal([]byte(string(message)), &messageMap)

    // find specific sender and receiver in clients map
    if messageMap["to"] == client.senderId || messageMap["from"] == client.senderId {
      select {
      // receive message from socket
        case client.send <- message:
        default:
          close(client.send)
          delete(h.clients, client)
      }
    }
  }
}

透過 if 的條件式,只讓 clients 中的 sender 跟 receiver 的 send channel 可以接收到 message。

這樣就算完成私密訊息的功能了。

Postman 測試

打開 postman,在 workspace 的地方新增三個 Websocket Request

/images/20220203/001.png

然後分別在 server Url 的地方輸入

  1. localhost:8080/ws?senderId=1111&receiverId=2222
  2. localhost:8080/ws?senderId=2222&receiverId=1111
  3. localhost:8080/ws?senderId=3333&receiverId=1111

/images/20220203/002.png

/images/20220203/003.png

/images/20220203/004.png

讓三個連線都 connect,接著就可以發現,我們接收到的訊息,永遠都只會是跟自己有關(是 sender or receiver)的了。

/images/20220203/005.png

/images/20220203/006.png

/images/20220203/007.png

如此一來,就算成功驗證我們的私訊聊天後端系統了。

成果

程式碼