亚洲视频二区_亚洲欧洲日本天天堂在线观看_日韩一区二区在线观看_中文字幕不卡一区

公告:魔扣目錄網(wǎng)為廣大站長提供免費(fèi)收錄網(wǎng)站服務(wù),提交前請(qǐng)做好本站友鏈:【 網(wǎng)站目錄:http://www.430618.com 】, 免友鏈快審服務(wù)(50元/站),

點(diǎn)擊這里在線咨詢客服
新站提交
  • 網(wǎng)站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會(huì)員:747

筆者 2019 年參加了一次 Gopher 大會(huì),有幸聽探探的架構(gòu)師分享了他們 2019 年微服務(wù)化的過程。

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

圖片來自 Pexels

本文快速搭建的 IM 系統(tǒng)也是使用 Go 語言來快速實(shí)現(xiàn)的,這里先和各位分享一下探探 App 的架構(gòu)圖:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

本文的目的是幫助讀者較為深入的理解 Socket 協(xié)議,并快速搭建一個(gè)高可用、可拓展的 IM 系統(tǒng)(文章標(biāo)題純屬引人眼球,不是真的,請(qǐng)讀者不要在意),同時(shí)幫助讀者了解 IM 系統(tǒng)后續(xù)可以做哪些優(yōu)化和改進(jìn)。

麻雀雖小,五臟俱全,該 IM 系統(tǒng)包含基本的注冊(cè)、登錄、添加好友基礎(chǔ)功能,另外提供單聊、群聊,并且支持發(fā)送文字、表情和圖片,在搭建的系統(tǒng)上,讀者可輕松的拓展語音、視頻聊天、發(fā)紅包等業(yè)務(wù)。

為了幫助讀者更清楚的理解 IM 系統(tǒng)的原理:

  • 第一節(jié)深入講解 WebSocket 協(xié)議,WebSocket 是長鏈接中比較常用的協(xié)議。
  • 第二節(jié)講解快速搭建 IM 系統(tǒng)的技巧和主要代碼實(shí)現(xiàn)。
  • 第三節(jié)對(duì) IM 系統(tǒng)的架構(gòu)升級(jí)和優(yōu)化提出一些建議和思路。
  • 最后章節(jié)做本文的回顧總結(jié)。

深入理解 WebSocket 協(xié)議

Web Sockets 的目標(biāo)是在一個(gè)單獨(dú)的持久連接上提供全雙工、雙向通信。在 JAVAscript 創(chuàng)建了 WebSocket 之后,會(huì)有一個(gè) HTTP 請(qǐng)求發(fā)送到瀏覽器以發(fā)起連接。

在取得服務(wù)器響應(yīng)后,建立的連接會(huì)將 HTTP 升級(jí)從 HTTP 協(xié)議交換為 WebSocket 協(xié)議。

由于 WebSocket 使用自定義的協(xié)議,所以 URL 模式也略有不同。未加密的連接不再是 http://,而是 ws://;加密的連接也不是 https://,而是 wss://。

在使用 WebSocket URL 時(shí),必須帶著這個(gè)模式,因?yàn)閷磉€有可能支持其他的模式。

使用自定義協(xié)議而非 HTTP 協(xié)議的好處是,能夠在客戶端和服務(wù)器之間發(fā)送非常少量的數(shù)據(jù),而不必?fù)?dān)心 HTTP 那樣字節(jié)級(jí)的開銷。由于傳遞的數(shù)據(jù)包很小,所以 WebSocket 非常適合移動(dòng)應(yīng)用。

上文中只是對(duì) Web Sockets 進(jìn)行了籠統(tǒng)的描述,接下來的篇幅會(huì)對(duì) Web Sockets 的細(xì)節(jié)實(shí)現(xiàn)進(jìn)行深入的探索。

本文接下來的幾個(gè)小節(jié)不會(huì)涉及到大量的代碼片段,但是會(huì)對(duì)相關(guān)的 API 和技術(shù)原理進(jìn)行分析,相信大家讀完下文之后再來看這段描述,會(huì)有一種豁然開朗的感覺。

①WebSocket 復(fù)用了 HTTP 的握手通道

“握手通道”是 HTTP 協(xié)議中客戶端和服務(wù)端通過"TCP 三次握手"建立的通信通道。

客戶端和服務(wù)端使用 HTTP 協(xié)議進(jìn)行的每次交互都需要先建立這樣一條“通道”,然后通過這條通道進(jìn)行通信。

我們熟悉的 Ajax 交互就是在這樣一個(gè)通道上完成數(shù)據(jù)傳輸?shù)模徊贿^ Ajax 交互是短連接,在一次 Request→Response 之后,“通道”連接就斷開了。

下面是 HTTP 協(xié)議中建立“握手通道”的過程示意圖:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

 

上文中我們提到:在 JavaScript 創(chuàng)建了 WebSocket 之后,會(huì)有一個(gè) HTTP 請(qǐng)求發(fā)送到瀏覽器以發(fā)起連接,然后服務(wù)端響應(yīng),這就是“握手“的過程。

在這個(gè)握手的過程當(dāng)中,客戶端和服務(wù)端主要做了兩件事情:

建立了一條連接“握手通道”用于通信:這點(diǎn)和 HTTP 協(xié)議相同,不同的是 HTTP 協(xié)議完成數(shù)據(jù)交互后就釋放了這條握手通道,這就是所謂的“短連接”,它的生命周期是一次數(shù)據(jù)交互的時(shí)間,通常是毫秒級(jí)別的。

將 HTTP 協(xié)議升級(jí)到 WebSocket 協(xié)議,并復(fù)用 HTTP 協(xié)議的握手通道,從而建立一條持久連接。

說到這里可能有人會(huì)問:HTTP 協(xié)議為什么不復(fù)用自己的“握手通道”,而非要在每次進(jìn)行數(shù)據(jù)交互的時(shí)候都通過 TCP 三次握手重新建立“握手通道”呢?

答案是這樣的:雖然“長連接”在客戶端和服務(wù)端交互的過程中省去了每次都建立“握手通道”的麻煩步驟。

但是維持這樣一條“長連接”是需要消耗服務(wù)器資源的,而在大多數(shù)情況下,這種資源的消耗又是不必要的,可以說 HTTP 標(biāo)準(zhǔn)的制定經(jīng)過了深思熟慮的考量。

到我們后邊說到 WebSocket 協(xié)議數(shù)據(jù)幀時(shí),大家可能就會(huì)明白,維持一條“長連接”服務(wù)端和客戶端需要做的事情太多了。

說完了握手通道,我們?cè)賮砜?HTTP 協(xié)議如何升級(jí)到 WebSocket 協(xié)議的。

②HTTP 協(xié)議升級(jí)為 WebSocket 協(xié)議

升級(jí)協(xié)議需要客戶端和服務(wù)端交流,服務(wù)端怎么知道要將 HTTP 協(xié)議升級(jí)到 WebSocket 協(xié)議呢?它一定是接收到了客戶端發(fā)送過來的某種信號(hào)。

下面是我從谷歌瀏覽器中截取的“客戶端發(fā)起協(xié)議升級(jí)請(qǐng)求的報(bào)文”,通過分析這段報(bào)文,我們能夠得到有關(guān) WebSocket 中協(xié)議升級(jí)的更多細(xì)節(jié)。

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

首先,客戶端發(fā)起協(xié)議升級(jí)請(qǐng)求。采用的是標(biāo)準(zhǔn)的 HTTP 報(bào)文格式,且只支持 GET 方法。

下面是重點(diǎn)請(qǐng)求的首部的意義:

Connection:Upgrade:表示要升級(jí)的協(xié)議。

Upgrade: websocket:表示要升級(jí)到 WebSocket 協(xié)議。

Sec-WebSocket-Version: 13:表示 WebSocket 的版本。

Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg==:與 Response Header 中的響應(yīng)首部 Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY= 是配套的,提供基本的防護(hù),比如惡意的連接或者無意的連接。

其中 Connection 就是我們前邊提到的,客戶端發(fā)送給服務(wù)端的信號(hào),服務(wù)端接受到信號(hào)之后,才會(huì)對(duì) HTTP 協(xié)議進(jìn)行升級(jí)。

那么服務(wù)端怎樣確認(rèn)客戶端發(fā)送過來的請(qǐng)求是否是合法的呢?在客戶端每次發(fā)起協(xié)議升級(jí)請(qǐng)求的時(shí)候都會(huì)產(chǎn)生一個(gè)唯一碼:Sec-WebSocket-Key。

服務(wù)端拿到這個(gè)碼后,通過一個(gè)算法進(jìn)行校驗(yàn),然后通過 Sec-WebSocket-Accept 響應(yīng)給客戶端,客戶端再對(duì) Sec-WebSocket-Accept 進(jìn)行校驗(yàn)來完成驗(yàn)證。

這個(gè)算法很簡單:

  • 將 Sec-WebSocket-Key 跟全局唯一的(GUID,[RFC4122])標(biāo)識(shí):258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接。
  • 通過 SHA1 計(jì)算出摘要,并轉(zhuǎn)成 base64 字符串。

258EAFA5-E914-47DA-95CA-C5AB0DC85B11 這個(gè)字符串又叫“魔串",至于為什么要使用它作為 WebSocket 握手計(jì)算中使用的字符串,這點(diǎn)我們無需關(guān)心,只需要知道它是 RFC 標(biāo)準(zhǔn)規(guī)定就可以了。

官方的解析也只是簡單的說此值不大可能被不明白 WebSocket 協(xié)議的網(wǎng)絡(luò)終端使用。

我們還是用世界上最好的語言來描述一下這個(gè)算法吧:

public function dohandshake($sock, $data, $key) { 
        if (preg_match("/Sec-WebSocket-Key: (.*)rn/", $data, $match)) { 
            $response = base64_encode(sha1($match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); 
            $upgrade  = "HTTP/1.1 101 Switching Protocolrn" . 
                "Upgrade: websocketrn" . 
                "Connection: Upgradern" . 
                "Sec-WebSocket-Accept: " . $response . "rnrn"; 
            socket_write($sock, $upgrade, strlen($upgrade)); 
            $this->isHand[$key] = true; 
        } 
    } 

服務(wù)端響應(yīng)客戶端的頭部信息和 HTTP 協(xié)議的格式是相同的,HTTP1.1 協(xié)議是以換行符(rn)分割的,我們可以通過正則匹配解析出 Sec-WebSocket-Accept 的值,這和我們使用 curl 工具模擬 get 請(qǐng)求是一個(gè)道理。

這樣展示結(jié)果似乎不太直觀,我們使用命令行 CLI 來根據(jù)上圖中的 Sec-WebSocket-Key 和握手算法來計(jì)算一下服務(wù)端返回的 Sec-WebSocket-Accept 是否正確:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

從圖中可以看到,通過算法算出來的 base64 字符串和 Sec-WebSocket-Accept 是一樣的。

那么假如服務(wù)端在握手的過程中返回一個(gè)錯(cuò)誤的 Sec-WebSocket-Accept 字符串會(huì)怎么樣呢?

當(dāng)然是客戶端會(huì)報(bào)錯(cuò),連接會(huì)建立失敗,大家可以嘗試一下,例如將全局唯一標(biāo)識(shí)符 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 改為 258EAFA5-E914-47DA-95CA-C5AB0DC85B12。

③WebSocket 的幀和數(shù)據(jù)分片傳輸

下圖是我做的一個(gè)測試:將小說《飄》的第一章內(nèi)容復(fù)制成文本數(shù)據(jù),通過客戶端發(fā)送到服務(wù)端,然后服務(wù)端響應(yīng)相同的信息完成了一次通信

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

可以看到一篇足足有將近 15000 字節(jié)的數(shù)據(jù)在客戶端和服務(wù)端完成通信只用了 150ms 的時(shí)間。

我們還可以看到瀏覽器控制臺(tái)中 Frame 欄中顯示的客戶端發(fā)送和服務(wù)端響應(yīng)的文本數(shù)據(jù),你一定驚訝 WebSocket 通信強(qiáng)大的數(shù)據(jù)傳輸能力。

數(shù)據(jù)是否真的像 Frame 中展示的那樣客戶端直接將一大篇文本數(shù)據(jù)發(fā)送到服務(wù)端,服務(wù)端接收到數(shù)據(jù)之后,再將一大篇文本數(shù)據(jù)返回給客戶端呢?

這當(dāng)然是不可能的,我們都知道 HTTP 協(xié)議是基于 TCP 實(shí)現(xiàn)的,HTTP 發(fā)送數(shù)據(jù)也是分包轉(zhuǎn)發(fā)的,就是將大數(shù)據(jù)根據(jù)報(bào)文形式分割成一小塊一小塊發(fā)送到服務(wù)端,服務(wù)端接收到客戶端發(fā)送的報(bào)文后,再將小塊的數(shù)據(jù)拼接組裝。

關(guān)于 HTTP 的分包策略,大家可以查看相關(guān)資料進(jìn)行研究,WebSocket 協(xié)議也是通過分片打包數(shù)據(jù)進(jìn)行轉(zhuǎn)發(fā)的,不過策略上和 HTTP 的分包不一樣。

Frame(幀)是 WebSocket 發(fā)送數(shù)據(jù)的基本單位,下邊是它的報(bào)文格式:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

報(bào)文內(nèi)容中規(guī)定了數(shù)據(jù)標(biāo)示,操作代碼、掩碼、數(shù)據(jù)、數(shù)據(jù)長度等格式。不太理解沒關(guān)系,下面我通過講解大家只要理解報(bào)文中重要標(biāo)志的作用就可以了。

首先我們明白了客戶端和服務(wù)端進(jìn)行 WebSocket 消息傳遞是這樣的:

  • 客戶端:將消息切割成多個(gè)幀,并發(fā)送給服務(wù)端。
  • 服務(wù)端:接收消息幀,并將關(guān)聯(lián)的幀重新組裝成完整的消息。

服務(wù)端在接收到客戶端發(fā)送的幀消息的時(shí)候,將這些幀進(jìn)行組裝,它怎么知道何時(shí)數(shù)據(jù)組裝完成的呢?

這就是報(bào)文中左上角 FIN(占一個(gè)比特)存儲(chǔ)的信息,1 表示這是消息的最后一個(gè)分片(fragment)如果是 0,表示不是消息的最后一個(gè)分片。

WebSocket 通信中,客戶端發(fā)送數(shù)據(jù)分片是有序的,這一點(diǎn)和 HTTP 不一樣。

HTTP 將消息分包之后,是并發(fā)無序的發(fā)送給服務(wù)端的,包信息在數(shù)據(jù)中的位置則在 HTTP 報(bào)文中存儲(chǔ),而 WebSocket 僅僅需要一個(gè) FIN 比特位就能保證將數(shù)據(jù)完整的發(fā)送到服務(wù)端。

接下來的 RSV1,RSV2,RSV3 三個(gè)比特位的作用又是什么呢?這三個(gè)標(biāo)志位是留給客戶端開發(fā)者和服務(wù)端開發(fā)者開發(fā)過程中協(xié)商進(jìn)行拓展的,默認(rèn)是 0。

拓展如何使用必須在握手的階段就協(xié)商好,其實(shí)握手本身也是客戶端和服務(wù)端的協(xié)商。

④WebSocket 連接保持和心跳檢測

WebSocket 是長連接,為了保持客戶端和服務(wù)端的實(shí)時(shí)雙向通信,需要確保客戶端和服務(wù)端之間的 TCP 通道保持連接沒有斷開。

但是對(duì)于長時(shí)間沒有數(shù)據(jù)往來的連接,如果依舊保持著,可能會(huì)浪費(fèi)服務(wù)端資源。

不排除有些場景,客戶端和服務(wù)端雖然長時(shí)間沒有數(shù)據(jù)往來,仍然需要保持連接,就比如說你幾個(gè)月沒有和一個(gè) QQ 好友聊天了,突然有一天他發(fā) QQ 消息告訴你他要結(jié)婚了,你還是能在第一時(shí)間收到。

那是因?yàn)椋蛻舳撕头?wù)端一直再采用心跳來檢查連接。客戶端和服務(wù)端的心跳連接檢測就像打乒乓球一樣:

  • 發(fā)送方→接收方:ping
  • 接收方→發(fā)送方:pong

等什么時(shí)候沒有 ping、pong 了,那么連接一定是存在問題了。

說了這么多,接下來我使用 Go 語言來實(shí)現(xiàn)一個(gè)心跳檢測,WebSocket 通信實(shí)現(xiàn)細(xì)節(jié)是一件繁瑣的事情,直接使用開源的類庫是比較不錯(cuò)的選擇,我使用的是:gorilla/websocket。

這個(gè)類庫已經(jīng)將 WebSocket 的實(shí)現(xiàn)細(xì)節(jié)(握手,數(shù)據(jù)解碼)封裝的很好啦。下面我就直接貼代碼了:

package main 
 
import ( 
    "net/http" 
    "time" 
 
    "github.com/gorilla/websocket" 
) 
 
var ( 
    //完成握手操作 
    upgrade = websocket.Upgrader{ 
       //允許跨域(一般來講,websocket都是獨(dú)立部署的) 
       CheckOrigin:func(r *http.Request) bool { 
            return true 
       }, 
    } 
) 
 
func wsHandler(w http.ResponseWriter, r *http.Request) { 
   var ( 
         conn *websocket.Conn 
         err error 
         data []byte 
   ) 
   //服務(wù)端對(duì)客戶端的http請(qǐng)求(升級(jí)為websocket協(xié)議)進(jìn)行應(yīng)答,應(yīng)答之后,協(xié)議升級(jí)為websocket,http建立連接時(shí)的tcp三次握手將保持。 
   if conn, err = upgrade.Upgrade(w, r, nil); err != nil { 
        return 
   } 
 
    //啟動(dòng)一個(gè)協(xié)程,每隔1s向客戶端發(fā)送一次心跳消息 
    go func() { 
        var ( 
            err error 
        ) 
        for { 
            if err = conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err != nil { 
                return 
            } 
            time.Sleep(1 * time.Second) 
        } 
    }() 
 
   //得到websocket的長鏈接之后,就可以對(duì)客戶端傳遞的數(shù)據(jù)進(jìn)行操作了 
   for { 
         //通過websocket長鏈接讀到的數(shù)據(jù)可以是text文本數(shù)據(jù),也可以是二進(jìn)制Binary 
        if _, data, err = conn.ReadMessage(); err != nil { 
            goto ERR 
     } 
     if err = conn.WriteMessage(websocket.TextMessage, data); err != nil { 
         goto ERR 
     } 
   } 
ERR: 
    //出錯(cuò)之后,關(guān)閉socket連接 
    conn.Close() 
} 
 
func main() { 
    http.HandleFunc("/ws", wsHandler) 
    http.ListenAndServe("0.0.0.0:7777", nil) 
} 

借助 Go 語言很容易搭建協(xié)程的特點(diǎn),我專門開啟了一個(gè)協(xié)程每秒向客戶端發(fā)送一條消息。

打開客戶端瀏覽器可以看到,F(xiàn)rame 中每秒的心跳數(shù)據(jù)一直在跳動(dòng),當(dāng)長鏈接斷開之后,心跳就沒有了,就像人沒有了心跳一樣:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

 

大家對(duì) WebSocket 協(xié)議已經(jīng)有了了解,接下來就讓我們一起快速搭建一個(gè)高性能、可拓展的 IM 系統(tǒng)吧。

快速搭建高性能、可拓展的 IM 系統(tǒng)

①系統(tǒng)架構(gòu)和代碼文件目錄結(jié)構(gòu)

下圖是一個(gè)比較完備的 IM 系統(tǒng)架構(gòu):包含了 C 端、接入層(通過協(xié)議接入)、S 端處理邏輯和分發(fā)消息、存儲(chǔ)層用來持久化數(shù)據(jù)。

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

我們本節(jié) C 端使用的是 Webapp, 通過 Go 語言渲染 Vue 模版快速實(shí)現(xiàn)功能,接入層使用的是 WebSocket 協(xié)議,前邊已經(jīng)進(jìn)行了深入的介紹。

S 端是我們實(shí)現(xiàn)的重點(diǎn),其中鑒權(quán)、登錄、關(guān)系管理、單聊和群聊的功能都已經(jīng)實(shí)現(xiàn),讀者可以在這部分功能的基礎(chǔ)上再拓展其他的功能,比如:視頻語音聊天、發(fā)紅包、朋友圈等業(yè)務(wù)模塊。

存儲(chǔ)層我們做的比較簡單,只是使用 MySQL 簡單持久化存儲(chǔ)了用戶關(guān)系,然后聊天中的圖片資源我們存儲(chǔ)到了本地文件中。

雖然我們的 IM 系統(tǒng)實(shí)現(xiàn)的比較簡化,但是讀者可以在次基礎(chǔ)上進(jìn)行改進(jìn)、完善、拓展,依然能夠作出高可用的企業(yè)級(jí)產(chǎn)品。

我們的系統(tǒng)服務(wù)使用 Go 語言構(gòu)建,代碼結(jié)構(gòu)比較簡潔,但是性能比較優(yōu)秀(這是 Java 和其他語言所無法比擬的),單機(jī)支持幾萬人的在線聊天。

下邊是代碼文件的目錄結(jié)構(gòu):

app 
│   ├── args 
│   │   ├── contact.go 
│   │   └── pagearg.go 
│   ├── controller           //控制器層,api入口 
│   │   ├── chat.go 
│   │   ├── contract.go 
│   │   ├── upload.go 
│   │   └── user.go 
│   ├── main.go             //程序入口 
│   ├── model               //數(shù)據(jù)定義與存儲(chǔ) 
│   │   ├── community.go 
│   │   ├── contract.go 
│   │   ├── init.go 
│   │   └── user.go 
│   ├── service             //邏輯實(shí)現(xiàn) 
│   │   ├── contract.go 
│   │   └── user.go 
│   ├── util                //幫助函數(shù)     
│   │   ├── md5.go 
│   │   ├── parse.go 
│   │   ├── resp.go 
│   │   └── string.go 
│   └── view                //模版資源 
│   │   ├── ... 
asset                       //js、css文件 
resource                    //上傳資源,上傳圖片會(huì)放到這里 

從入口函數(shù) main.go 開始,我們定義了 Controller 層,是客戶端 API 的入口。Service 用來處理主要的用戶邏輯,消息分發(fā)、用戶管理都在這里實(shí)現(xiàn)。

Model 層定義了一些數(shù)據(jù)表,主要是用戶注冊(cè)和用戶好友關(guān)系、群組等信息,存儲(chǔ)到 MySQL。

Util 包下是一些幫助函數(shù),比如加密、請(qǐng)求響應(yīng)等。View 下邊存儲(chǔ)了模版資源信息,上邊所說的這些都在 App 文件夾下存儲(chǔ),外層還有 asset 用來存儲(chǔ) css、js 文件和聊天中會(huì)用到的表情圖片等。

Resource 下存儲(chǔ)用戶聊天中的圖片或者視頻等文件。總體來講,我們的代碼目錄機(jī)構(gòu)還是比較簡潔清晰的。

了解了我們要搭建的 IM 系統(tǒng)架構(gòu),我們?cè)賮砜匆幌录軜?gòu)重點(diǎn)實(shí)現(xiàn)的功能吧。

②10 行代碼萬能模版渲染

Go 語言提供了強(qiáng)大的 html 渲染能力,非常簡單的構(gòu)建 Web 應(yīng)用,下邊是實(shí)現(xiàn)模版渲染的代碼,它太簡單了,以至于可以直接在 main.go 函數(shù)中實(shí)現(xiàn):

func registerView() { 
    tpl, err := template.ParseGlob("./app/view/**/*") 
    if err != nil { 
        log.Fatal(err.Error()) 
    } 
    for _, v := range tpl.Templates() { 
        tplName := v.Name() 
        http.HandleFunc(tplName, func(writer http.ResponseWriter, request *http.Request) { 
            tpl.ExecuteTemplate(writer, tplName, nil) 
        }) 
    } 
} 
... 
func main() { 
    ...... 
    http.Handle("/asset/", http.FileServer(http.Dir("."))) 
    http.Handle("/resource/", http.FileServer(http.Dir("."))) 
    registerView() 
    log.Fatal(http.ListenAndServe(":8081", nil)) 
} 

Go 實(shí)現(xiàn)靜態(tài)資源服務(wù)器也很簡單,只需要調(diào)用 http.FileServer 就可以了,這樣 HTML 文件就可以很輕松的訪問依賴的 js、css 和圖標(biāo)文件了。

使用 http/template 包下的 ParseGlob、ExecuteTemplate 又可以很輕松的解析 Web 頁面,這些工作完全不依賴與 Nginx。

現(xiàn)在我們就完成了登錄、注冊(cè)、聊天 C 端界面的構(gòu)建工作:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 


2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

 

③注冊(cè)、登錄和鑒權(quán)

之前我們提到過,對(duì)于注冊(cè)、登錄和好友關(guān)系管理,我們需要有一張 user 表來存儲(chǔ)用戶信息。

我們使用 github.com/go-xorm/xorm 來操作 MySQL,首先看一下 MySQL 表的設(shè)計(jì):

app/model/user.go:

package model 
 
import "time" 
 
const ( 
    SexWomen = "W" 
    SexMan = "M" 
    SexUnknown = "U" 
) 
 
type User struct { 
    Id         int64     `xorm:"pk autoincr bigint(64)" form:"id" json:"id"` 
    Mobile   string         `xorm:"varchar(20)" form:"mobile" json:"mobile"` 
    Passwd       string `xorm:"varchar(40)" form:"passwd" json:"-"`   // 用戶密碼 md5(passwd + salt) 
    Avatar     string       `xorm:"varchar(150)" form:"avatar" json:"avatar"` 
    Sex        string   `xorm:"varchar(2)" form:"sex" json:"sex"` 
    Nickname    string  `xorm:"varchar(20)" form:"nickname" json:"nickname"` 
    Salt       string   `xorm:"varchar(10)" form:"salt" json:"-"` 
    Online     int  `xorm:"int(10)" form:"online" json:"online"`   //是否在線 
    Token      string   `xorm:"varchar(40)" form:"token" json:"token"`   //用戶鑒權(quán) 
    Memo      string    `xorm:"varchar(140)" form:"memo" json:"memo"` 
    Createat   time.Time    `xorm:"datetime" form:"createat" json:"createat"`   //創(chuàng)建時(shí)間, 統(tǒng)計(jì)用戶增量時(shí)使用 
} 

我們 user 表中存儲(chǔ)了用戶名、密碼、頭像、用戶性別、手機(jī)號(hào)等一些重要的信息,比較重要的是我們也存儲(chǔ)了 Token 標(biāo)示用戶在用戶登錄之后,HTTP 協(xié)議升級(jí)為 WebSocket 協(xié)議進(jìn)行鑒權(quán),這個(gè)細(xì)節(jié)點(diǎn)我們前邊提到過,下邊會(huì)有代碼演示。

接下來我們看一下 model 初始化要做的一些事情吧:

app/model/init.go:

package model 
 
import ( 
    "errors" 
    "fmt" 
    _ "github.com/go-sql-driver/mysql" 
    "github.com/go-xorm/xorm" 
    "log" 
) 
 
var DbEngine *xorm.Engine 
 
func init() { 
    driverName := "mysql" 
    dsnName := "root:root@(127.0.0.1:3306)/chat?charset=utf8" 
    err := errors.New("") 
    DbEngine, err = xorm.NewEngine(driverName, dsnName) 
    if err != nil && err.Error() != ""{ 
        log.Fatal(err) 
    } 
    DbEngine.ShowSQL(true) 
    //設(shè)置數(shù)據(jù)庫連接數(shù) 
    DbEngine.SetMaxOpenConns(10) 
    //自動(dòng)創(chuàng)建數(shù)據(jù)庫 
    DbEngine.Sync(new(User), new(Community), new(Contact)) 
 
    fmt.Println("init database ok!") 
} 

我們創(chuàng)建一個(gè) DbEngine 全局 MySQL 連接對(duì)象,設(shè)置了一個(gè)大小為 10 的連接池。

Model 包里的 init 函數(shù)在程序加載的時(shí)候會(huì)先執(zhí)行,對(duì) Go 語言熟悉的同學(xué)應(yīng)該知道這一點(diǎn)。

我們還設(shè)置了一些額外的參數(shù)用于調(diào)試程序,比如:設(shè)置打印運(yùn)行中的 SQL,自動(dòng)的同步數(shù)據(jù)表等,這些功能在生產(chǎn)環(huán)境中可以關(guān)閉。

我們的 Model 初始化工作就做完了,非常簡陋,在實(shí)際的項(xiàng)目中,像數(shù)據(jù)庫的用戶名、密碼、連接數(shù)和其他的配置信息,建議設(shè)置到配置文件中,然后讀取,而不像本文硬編碼的程序中。

注冊(cè)是一個(gè)普通的 API 程序,對(duì)于 Go 語言來說,完成這件工作太簡單了,我們來看一下代碼:

############################ 
//app/controller/user.go 
############################ 
...... 
//用戶注冊(cè) 
func UserRegister(writer http.ResponseWriter, request *http.Request) { 
    var user model.User 
    util.Bind(request, &user) 
    user, err := UserService.UserRegister(user.Mobile, user.Passwd, user.Nickname, user.Avatar, user.Sex) 
    if err != nil { 
        util.RespFail(writer, err.Error()) 
    } else { 
        util.RespOk(writer, user, "") 
    } 
} 
...... 
############################ 
//app/service/user.go 
############################ 
...... 
type UserService struct{} 
 
//用戶注冊(cè) 
func (s *UserService) UserRegister(mobile, plainPwd, nickname, avatar, sex string) (user model.User, err error) { 
    registerUser := model.User{} 
    _, err = model.DbEngine.Where("mobile=? ", mobile).Get(®isterUser) 
    if err != nil { 
        return registerUser, err 
    } 
    //如果用戶已經(jīng)注冊(cè),返回錯(cuò)誤信息 
    if registerUser.Id > 0 { 
        return registerUser, errors.New("該手機(jī)號(hào)已注冊(cè)") 
    } 
 
    registerUser.Mobile = mobile 
    registerUser.Avatar = avatar 
    registerUser.Nickname = nickname 
    registerUser.Sex = sex 
    registerUser.Salt = fmt.Sprintf("%06d", rand.Int31n(10000)) 
    registerUser.Passwd = util.MakePasswd(plainPwd, registerUser.Salt) 
    registerUser.Createat = time.Now() 
    //插入用戶信息 
    _, err = model.DbEngine.InsertOne(®isterUser) 
 
    return registerUser,  err 
} 
...... 
############################ 
//main.go 
############################ 
...... 
func main() { 
    http.HandleFunc("/user/register", controller.UserRegister) 
} 

首先我們使用 util.Bind(request, &user) 將用戶參數(shù)綁定到 user 對(duì)象上,使用的是 util 包中的 Bind 函數(shù),具體實(shí)現(xiàn)細(xì)節(jié)讀者可以自行研究,主要模仿了 Gin 框架的參數(shù)綁定,可以拿來即用,非常方便。

然后我們根據(jù)用戶手機(jī)號(hào)搜索數(shù)據(jù)庫中是否已經(jīng)存在,如果不存在就插入到數(shù)據(jù)庫中,返回注冊(cè)成功信息,邏輯非常簡單。

登錄邏輯更簡單:

############################ 
//app/controller/user.go 
############################ 
... 
//用戶登錄 
func UserLogin(writer http.ResponseWriter, request *http.Request) { 
    request.ParseForm() 
 
    mobile := request.PostForm.Get("mobile") 
    plainpwd := request.PostForm.Get("passwd") 
 
    //校驗(yàn)參數(shù) 
    if len(mobile) == 0 || len(plainpwd) == 0 { 
        util.RespFail(writer, "用戶名或密碼不正確") 
    } 
 
    loginUser, err := UserService.Login(mobile, plainpwd) 
    if err != nil { 
        util.RespFail(writer, err.Error()) 
    } else { 
        util.RespOk(writer, loginUser, "") 
    } 
} 
... 
############################ 
//app/service/user.go 
############################ 
... 
func (s *UserService) Login(mobile, plainpwd string) (user model.User, err error) { 
    //數(shù)據(jù)庫操作 
    loginUser := model.User{} 
    model.DbEngine.Where("mobile = ?", mobile).Get(&loginUser) 
    if loginUser.Id == 0 { 
        return loginUser, errors.New("用戶不存在") 
    } 
    //判斷密碼是否正確 
    if !util.ValidatePasswd(plainpwd, loginUser.Salt, loginUser.Passwd) { 
        return loginUser, errors.New("密碼不正確") 
    } 
    //刷新用戶登錄的token值 
    token := util.GenRandomStr(32) 
    loginUser.Token = token 
    model.DbEngine.ID(loginUser.Id).Cols("token").Update(&loginUser) 
 
    //返回新用戶信息 
    return loginUser, nil 
} 
... 
############################ 
//main.go 
############################ 
...... 
func main() { 
    http.HandleFunc("/user/login", controller.UserLogin) 
} 

實(shí)現(xiàn)了登錄邏輯,接下來我們就到了用戶首頁,這里列出了用戶列表,點(diǎn)擊即可進(jìn)入聊天頁面。

用戶也可以點(diǎn)擊下邊的 Tab 欄查看自己所在的群組,可以由此進(jìn)入群組聊天頁面。

具體這些工作還需要讀者自己開發(fā)用戶列表、添加好友、創(chuàng)建群組、添加群組等功能,這些都是一些普通的 API 開發(fā)工作,我們的代碼程序中也實(shí)現(xiàn)了,讀者可以拿去修改使用,這里就不再演示了。

我們?cè)僦攸c(diǎn)看一下用戶鑒權(quán)這一塊吧,用戶鑒權(quán)是指用戶點(diǎn)擊聊天進(jìn)入聊天界面時(shí),客戶端會(huì)發(fā)送一個(gè) GET 請(qǐng)求給服務(wù)端。

請(qǐng)求建立一條 WebSocket 長連接,服務(wù)端收到建立連接的請(qǐng)求之后,會(huì)對(duì)客戶端請(qǐng)求進(jìn)行校驗(yàn),以確實(shí)是否建立長連接,然后將這條長連接的句柄添加到 Map 當(dāng)中(因?yàn)榉?wù)端不僅僅對(duì)一個(gè)客戶端服務(wù),可能存在千千萬萬個(gè)長連接)維護(hù)起來。

我們下邊來看具體代碼實(shí)現(xiàn):

############################ 
//app/controller/chat.go 
############################ 
...... 
//本核心在于形成userid和Node的映射關(guān)系 
type Node struct { 
    Conn *websocket.Conn 
    //并行轉(zhuǎn)串行, 
    DataQueue chan []byte 
    GroupSets set.Interface 
} 
...... 
//userid和Node映射關(guān)系表 
var clientMap map[int64]*Node = make(map[int64]*Node, 0) 
//讀寫鎖 
var rwlocker sync.RWMutex 
//實(shí)現(xiàn)聊天的功能 
func Chat(writer http.ResponseWriter, request *http.Request) { 
    query := request.URL.Query() 
    id := query.Get("id") 
    token := query.Get("token") 
    userId, _ := strconv.ParseInt(id, 10, 64) 
    //校驗(yàn)token是否合法 
    islegal := checkToken(userId, token) 
 
    conn, err := (&websocket.Upgrader{ 
        CheckOrigin: func(r *http.Request) bool { 
            return islegal 
        }, 
    }).Upgrade(writer, request, nil) 
 
    if err != nil { 
        log.Println(err.Error()) 
        return 
    } 
    //獲得websocket鏈接conn 
    node := &Node{ 
        Conn:      conn, 
        DataQueue: make(chan []byte, 50), 
        GroupSets: set.New(set.ThreadSafe), 
    } 
 
    //獲取用戶全部群Id 
    comIds := concatService.SearchComunityIds(userId) 
    for _, v := range comIds { 
        node.GroupSets.Add(v) 
    } 
 
    rwlocker.Lock() 
    clientMap[userId] = node 
    rwlocker.Unlock() 
 
    //開啟協(xié)程處理發(fā)送邏輯 
    go sendproc(node) 
 
    //開啟協(xié)程完成接收邏輯 
    go recvproc(node) 
 
    sendMsg(userId, []byte("welcome!")) 
} 
 
...... 
 
//校驗(yàn)token是否合法 
func checkToken(userId int64, token string) bool { 
    user := UserService.Find(userId) 
    return user.Token == token 
} 
 
...... 
 
############################ 
//main.go 
############################ 
...... 
func main() { 
    http.HandleFunc("/chat", controller.Chat) 
} 
...... 

進(jìn)入聊天室,客戶端發(fā)起 /chat 的 GET 請(qǐng)求,服務(wù)端首先創(chuàng)建了一個(gè) Node 結(jié)構(gòu)體,用來存儲(chǔ)和客戶端建立起來的 WebSocket 長連接句柄。

每一個(gè)句柄都有一個(gè)管道 DataQueue,用來收發(fā)信息,GroupSets 是客戶端對(duì)應(yīng)的群組信息,后邊我們會(huì)提到。

type Node struct { 
    Conn *websocket.Conn 
    //并行轉(zhuǎn)串行, 
    DataQueue chan []byte 
    GroupSets set.Interface 
} 

服務(wù)端創(chuàng)建了一個(gè) Map,將客戶端用戶 ID 和其 Node 關(guān)聯(lián)起來:

//userid和Node映射關(guān)系表 
var clientMap map[int64]*Node = make(map[int64]*Node, 0) 

接下來是主要的用戶邏輯了,服務(wù)端接收到客戶端的參數(shù)之后,首先校驗(yàn) Token 是否合法,由此確定是否要升級(jí) HTTP 協(xié)議到 WebSocket 協(xié)議,建立長連接,這一步稱為鑒權(quán)。

//校驗(yàn)token是否合法 
islegal := checkToken(userId, token) 
 
conn, err := (&websocket.Upgrader{ 
  CheckOrigin: func(r *http.Request) bool { 
    return islegal 
  }, 
}).Upgrade(writer, request, nil) 

鑒權(quán)成功以后,服務(wù)端初始化一個(gè) Node,搜索該客戶端用戶所在的群組 ID,填充到群組的 GroupSets 屬性中。

然后將 Node 節(jié)點(diǎn)添加到 ClientMap 中維護(hù)起來,我們對(duì) ClientMap 的操作一定要加鎖,因?yàn)?Go 語言在并發(fā)情況下,對(duì) Map 的操作并不保證原子安全:

//獲得websocket鏈接conn 
    node := &Node{ 
        Conn:      conn, 
        DataQueue: make(chan []byte, 50), 
        GroupSets: set.New(set.ThreadSafe), 
    } 
 
    //獲取用戶全部群Id 
    comIds := concatService.SearchComunityIds(userId) 
    for _, v := range comIds { 
        node.GroupSets.Add(v) 
    } 
 
    rwlocker.Lock() 
    clientMap[userId] = node 
    rwlocker.Unlock() 

服務(wù)端和客戶端建立了長鏈接之后,會(huì)開啟兩個(gè)協(xié)程專門來處理客戶端消息的收發(fā)工作,對(duì)于 Go 語言來說,維護(hù)協(xié)程的代價(jià)是很低的。

所以說我們的單機(jī)程序可以很輕松的支持成千上完的用戶聊天,這還是在沒有優(yōu)化的情況下。

...... 
//開啟協(xié)程處理發(fā)送邏輯 
    go sendproc(node) 
 
    //開啟協(xié)程完成接收邏輯 
    go recvproc(node) 
 
    sendMsg(userId, []byte("welcome!")) 
......     

至此,我們的鑒權(quán)工作也已經(jīng)完成了,客戶端和服務(wù)端的連接已經(jīng)建立好了,接下來我們就來實(shí)現(xiàn)具體的聊天功能吧。

④實(shí)現(xiàn)單聊和群聊

實(shí)現(xiàn)聊天的過程中,消息體的設(shè)計(jì)至關(guān)重要,消息體設(shè)計(jì)的合理,功能拓展起來就非常的方便,后期維護(hù)、優(yōu)化起來也比較簡單。

我們先來看一下,我們消息體的設(shè)計(jì):

############################ 
//app/controller/chat.go 
############################ 
type Message struct { 
    Id      int64  `json:"id,omitempty" form:"id"`           //消息ID 
    Userid  int64  `json:"userid,omitempty" form:"userid"`   //誰發(fā)的 
    Cmd     int    `json:"cmd,omitempty" form:"cmd"`         //群聊還是私聊 
    Dstid   int64  `json:"dstid,omitempty" form:"dstid"`     //對(duì)端用戶ID/群ID 
    Media   int    `json:"media,omitempty" form:"media"`     //消息按照什么樣式展示 
    Content string `json:"content,omitempty" form:"content"` //消息的內(nèi)容 
    Pic     string `json:"pic,omitempty" form:"pic"`         //預(yù)覽圖片 
    Url     string `json:"url,omitempty" form:"url"`         //服務(wù)的URL 
    Memo    string `json:"memo,omitempty" form:"memo"`       //簡單描述 
    Amount  int    `json:"amount,omitempty" form:"amount"`   //其他和數(shù)字相關(guān)的 
} 

每一條消息都有一個(gè)唯一的 ID,將來我們可以對(duì)消息持久化存儲(chǔ),但是我們系統(tǒng)中并沒有做這件工作,讀者可根據(jù)需要自行完成。

然后是 userid,發(fā)起消息的用戶,對(duì)應(yīng)的是 dstid,要將消息發(fā)送給誰。還有一個(gè)參數(shù)非常重要,就是 cmd,它表示是群聊還是私聊。

群聊和私聊的代碼處理邏輯有所區(qū)別,我們?yōu)榇藢iT定義了一些 cmd 常量:

//定義命令行格式 
const ( 
    CmdSingleMsg = 10 
    CmdRoomMsg   = 11 
    CmdHeart     = 0 
) 

Media 是媒體類型,我們都知道微信支持語音、視頻和各種其他的文件傳輸,我們?cè)O(shè)置了該參數(shù)之后,讀者也可以自行拓展這些功能。

Content 是消息文本,是聊天中最常用的一種形式。Pic 和 URL 是為圖片和其他鏈接資源所設(shè)置的。

Memo 是簡介,Amount 是和數(shù)字相關(guān)的信息,比如說發(fā)紅包業(yè)務(wù)有可能使用到該字段。

消息體的設(shè)計(jì)就是這樣,基于此消息體,我們來看一下,服務(wù)端如何收發(fā)消息,實(shí)現(xiàn)單聊和群聊吧。

還是從上一節(jié)說起,我們?yōu)槊恳粋€(gè)客戶端長鏈接開啟了兩個(gè)協(xié)程,用于收發(fā)消息,聊天的邏輯就在這兩個(gè)協(xié)程當(dāng)中實(shí)現(xiàn)。

############################ 
//app/controller/chat.go 
############################ 
...... 
//發(fā)送邏輯 
func sendproc(node *Node) { 
    for { 
        select { 
        case data := <-node.DataQueue: 
            err := node.Conn.WriteMessage(websocket.TextMessage, data) 
            if err != nil { 
                log.Println(err.Error()) 
                return 
            } 
        } 
    } 
} 
 
//接收邏輯 
func recvproc(node *Node) { 
    for { 
        _, data, err := node.Conn.ReadMessage() 
        if err != nil { 
            log.Println(err.Error()) 
            return 
        } 
 
        dispatch(data) 
        //todo對(duì)data進(jìn)一步處理 
        fmt.Printf("recv<=%s", data) 
    } 
} 
...... 
//后端調(diào)度邏輯處理 
func dispatch(data []byte) { 
    msg := Message{} 
    err := json.Unmarshal(data, &msg) 
    if err != nil { 
        log.Println(err.Error()) 
        return 
    } 
    switch msg.Cmd { 
    case CmdSingleMsg: 
        sendMsg(msg.Dstid, data) 
    case CmdRoomMsg: 
        for _, v := range clientMap { 
            if v.GroupSets.Has(msg.Dstid) { 
                v.DataQueue <- data 
            } 
        } 
    case CmdHeart: 
        //檢測客戶端的心跳 
    } 
} 
 
//發(fā)送消息,發(fā)送到消息的管道 
func sendMsg(userId int64, msg []byte) { 
    rwlocker.RLock() 
    node, ok := clientMap[userId] 
    rwlocker.RUnlock() 
    if ok { 
        node.DataQueue <- msg 
    } 
} 
...... 

服務(wù)端向客戶端發(fā)送消息邏輯比較簡單,就是將客戶端發(fā)送過來的消息,直接添加到目標(biāo)用戶 Node 的 Channel 中去就好了。

通過 WebSocket 的 WriteMessage 就可以實(shí)現(xiàn)此功能:

func sendproc(node *Node) { 
    for { 
        select { 
        case data := <-node.DataQueue: 
            err := node.Conn.WriteMessage(websocket.TextMessage, data) 
            if err != nil { 
                log.Println(err.Error()) 
                return 
            } 
        } 
    } 
} 

收發(fā)邏輯是這樣的,服務(wù)端通過 WebSocket 的 ReadMessage 方法接收到用戶信息,然后通過 dispatch 方法進(jìn)行調(diào)度:

func recvproc(node *Node) { 
    for { 
        _, data, err := node.Conn.ReadMessage() 
        if err != nil { 
            log.Println(err.Error()) 
            return 
        } 
 
        dispatch(data) 
        //todo對(duì)data進(jìn)一步處理 
        fmt.Printf("recv<=%s", data) 
    } 
} 

dispatch 方法所做的工作有兩件:

  • 解析消息體到 Message 中。
  • 根據(jù)消息類型,將消息體添加到不同用戶或者用戶組的 Channel 當(dāng)中。

Go 語言中的 Channel 是協(xié)程間通信的強(qiáng)大工具,dispatch 只要將消息添加到 Channel 當(dāng)中,發(fā)送協(xié)程就會(huì)獲取到信息發(fā)送給客戶端,這樣就實(shí)現(xiàn)了聊天功能。

單聊和群聊的區(qū)別只是服務(wù)端將消息發(fā)送給群組還是個(gè)人,如果發(fā)送給群組,程序會(huì)遍歷整個(gè) clientMap,看看哪個(gè)用戶在這個(gè)群組當(dāng)中,然后將消息發(fā)送。

其實(shí)更好的實(shí)踐是我們?cè)倬S護(hù)一個(gè)群組和用戶關(guān)系的 Map,這樣在發(fā)送群組消息的時(shí)候,取得用戶信息就比遍歷整個(gè) clientMap 代價(jià)要小很多了。

func dispatch(data []byte) { 
    msg := Message{} 
    err := json.Unmarshal(data, &msg) 
    if err != nil { 
        log.Println(err.Error()) 
        return 
    } 
    switch msg.Cmd { 
    case CmdSingleMsg: 
        sendMsg(msg.Dstid, data) 
    case CmdRoomMsg: 
        for _, v := range clientMap { 
            if v.GroupSets.Has(msg.Dstid) { 
                v.DataQueue <- data 
            } 
        } 
    case CmdHeart: 
        //檢測客戶端的心跳 
    } 
} 
...... 
func sendMsg(userId int64, msg []byte) { 
    rwlocker.RLock() 
    node, ok := clientMap[userId] 
    rwlocker.RUnlock() 
    if ok { 
        node.DataQueue <- msg 
    } 
} 

可以看到,通過 Channel,我們實(shí)現(xiàn)用戶聊天功能還是非常方便的,代碼可讀性很強(qiáng),構(gòu)建的程序也很健壯。

下邊是筆者本地聊天的示意圖:

2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 


2小時(shí)快速搭建一個(gè)高可用的IM系統(tǒng)

 

⑤發(fā)送表情和圖片

下邊我們?cè)賮砜匆幌铝奶熘薪?jīng)常使用到的發(fā)送表情和圖片功能是如何實(shí)現(xiàn)的吧。

其實(shí)表情也是小圖片,只是和聊天中圖片不同的是,表情圖片比較小,可以緩存在客戶端,或者直接存放到客戶端代碼的代碼文件中(不過現(xiàn)在微信聊天中有的表情包都是通過網(wǎng)絡(luò)傳輸?shù)?。

下邊是一個(gè)聊天中返回的圖標(biāo)文本數(shù)據(jù):

{ 
"dstid":1, 
"cmd":10, 
"userid":2, 
"media":4, 
"url":"/asset/plugins/doutu//emoj/2.gif" 
} 

客戶端拿到 URL 后,就加載本地的小圖標(biāo)。聊天中用戶發(fā)送圖片也是一樣的原理,不過聊天中用戶的圖片需要先上傳到服務(wù)器,然后服務(wù)端返回 URL,客戶端再進(jìn)行加載,我們的 IM 系統(tǒng)也支持此功能。

我們看一下圖片上傳的程序:

############################ 
//app/controller/upload.go 
############################ 
func init() { 
    os.MkdirAll("./resource", os.ModePerm) 
} 
 
func FileUpload(writer http.ResponseWriter, request *http.Request) { 
    UploadLocal(writer, request) 
} 
 
//將文件存儲(chǔ)在本地/im_resource目錄下 
func UploadLocal(writer http.ResponseWriter, request *http.Request) { 
    //獲得上傳源文件 
    srcFile, head, err := request.FormFile("file") 
    if err != nil { 
        util.RespFail(writer, err.Error()) 
    } 
    //創(chuàng)建一個(gè)新的文件 
    suffix := ".png" 
    srcFilename := head.Filename 
    splitMsg := strings.Split(srcFilename, ".") 
    if len(splitMsg) > 1 { 
        suffix = "." + splitMsg[len(splitMsg)-1] 
    } 
    filetype := request.FormValue("filetype") 
    if len(filetype) > 0 { 
        suffix = filetype 
    } 
    filename := fmt.Sprintf("%d%s%s", time.Now().Unix(), util.GenRandomStr(32), suffix) 
    //創(chuàng)建文件 
    filepath := "./resource/" + filename 
    dstfile, err := os.Create(filepath) 
    if err != nil { 
        util.RespFail(writer, err.Error()) 
        return 
    } 
    //將源文件拷貝到新文件 
    _, err = io.Copy(dstfile, srcFile) 
    if err != nil { 
        util.RespFail(writer, err.Error()) 
        return 
    } 
 
    util.RespOk(writer, filepath, "") 
} 
...... 
############################ 
//main.go 
############################ 
func main() { 
    http.HandleFunc("/attach/upload", controller.FileUpload) 
} 

我們將文件存放到本地的一個(gè)磁盤文件夾下,然后發(fā)送給客戶端路徑,客戶端通過路徑加載相關(guān)的圖片信息。

關(guān)于發(fā)送圖片,我們雖然實(shí)現(xiàn)功能,但是做的太簡單了,我們?cè)诮酉聛淼恼鹿?jié)詳細(xì)的和大家探討一下系統(tǒng)優(yōu)化相關(guān)的方案。怎樣讓我們的系統(tǒng)在生產(chǎn)環(huán)境中用的更好。

程序優(yōu)化和系統(tǒng)架構(gòu)升級(jí)方案

我們上邊實(shí)現(xiàn)了一個(gè)功能健全的 IM 系統(tǒng),要將該系統(tǒng)應(yīng)用在企業(yè)的生產(chǎn)環(huán)境中,需要對(duì)代碼和系統(tǒng)架構(gòu)做優(yōu)化,才能實(shí)現(xiàn)真正的高可用。

本節(jié)主要從代碼優(yōu)化和架構(gòu)升級(jí)上談一些個(gè)人觀點(diǎn),能力有限不可能面面俱到,希望讀者也在評(píng)論區(qū)給出更多好的建議。

代碼優(yōu)化

我們的代碼沒有使用框架,函數(shù)和 API 都寫的比較簡陋,雖然進(jìn)行了簡單的結(jié)構(gòu)化,但是很多邏輯并沒有解耦,所以建議大家業(yè)界比較成熟的框架對(duì)代碼進(jìn)行重構(gòu),Gin 就是一個(gè)不錯(cuò)的選擇。

系統(tǒng)程序中使用 clientMap 來存儲(chǔ)客戶端長鏈接信息,Go 語言中對(duì)于大 Map 的讀寫要加鎖,有一定的性能限制。

在用戶量特別大的情況下,讀者可以對(duì) clientMap 做拆分,根據(jù)用戶 ID 做 Hash 或者采用其他的策略,也可以將這些長鏈接句柄存放到 redis 中。

上邊提到圖片上傳的過程,有很多可以優(yōu)化的地方,首先是圖片壓縮(微信也是這樣做的),圖片資源的壓縮不僅可以加快傳輸速度,還可以減少服務(wù)端存儲(chǔ)的空間。

另外對(duì)于圖片資源來說,實(shí)際上服務(wù)端只需要存儲(chǔ)一份數(shù)據(jù)就夠了,讀者可以在圖片上傳的時(shí)候做 Hash 校驗(yàn)。

如果資源文件已經(jīng)存在了,就不需要再次上傳了,而是直接將 URL 返回給客戶端(各大網(wǎng)盤廠商的妙傳功能就是這樣實(shí)現(xiàn)的)。

代碼還有很多優(yōu)化的地方,比如我們可以將鑒權(quán)做的更好,使用 wss:// 代替 ws://。

在一些安全領(lǐng)域,可以對(duì)消息體進(jìn)行加密,在高并發(fā)領(lǐng)域,可以對(duì)消息體進(jìn)行壓縮。

對(duì) MySQL 連接池再做優(yōu)化,將消息持久化存儲(chǔ)到 Mongo,避免對(duì)數(shù)據(jù)庫頻繁的寫入,將單條寫入改為多條一塊寫入;為了使程序耗費(fèi)更少的 CPU,降低對(duì)消息體進(jìn)行 Json 編碼的次數(shù),一次編碼,多次使用......

系統(tǒng)架構(gòu)升級(jí)

我們的系統(tǒng)太過于簡單,所在在架構(gòu)升級(jí)上,有太多的工作可以做,筆者在這里只提幾點(diǎn)比較重要的:

①應(yīng)用/資源服務(wù)分離

我們所說的資源指的是圖片、視頻等文件,可以選擇成熟廠商的 Cos,或者自己搭建文件服務(wù)器也是可以的,如果資源量比較大,用戶比較廣,CDN 是不錯(cuò)的選擇。

②突破系統(tǒng)連接數(shù),搭建分布式環(huán)境

對(duì)于服務(wù)器的選擇,一般會(huì)選擇 linux,Linux 下一切皆文件,長鏈接也是一樣。

單機(jī)的系統(tǒng)連接數(shù)是有限制的,一般來說能達(dá)到 10 萬就很不錯(cuò)了,所以在用戶量增長到一定程序,需要搭建分布式。

分布式的搭建就要優(yōu)化程序,因?yàn)殚L鏈接句柄分散到不同的機(jī)器,實(shí)現(xiàn)消息廣播和分發(fā)是首先要解決的問題,筆者這里不深入闡述了,一來是沒有足夠的經(jīng)驗(yàn),二來是解決方案有太多的細(xì)節(jié)需要探討。

搭建分布式環(huán)境所面臨的問題還有:怎樣更好的彈性擴(kuò)容、應(yīng)對(duì)突發(fā)事件等。

③業(yè)務(wù)功能分離

我們上邊將用戶注冊(cè)、添加好友等功能和聊天功能放到了一起,真實(shí)的業(yè)務(wù)場景中可以將它們做分離,將用戶注冊(cè)、添加好友、創(chuàng)建群組放到一臺(tái)服務(wù)器上,將聊天功能放到另外的服務(wù)器上。

業(yè)務(wù)的分離不僅使功能邏輯更加清晰,還能更有效的利用服務(wù)器資源。

④減少數(shù)據(jù)庫I/O,合理利用緩存

我們的系統(tǒng)沒有將消息持久化,用戶信息持久化到 MySQL 中去。

在業(yè)務(wù)當(dāng)中,如果要對(duì)消息做持久化儲(chǔ)存,就要考慮數(shù)據(jù)庫 I/O 的優(yōu)化,簡單講:合并數(shù)據(jù)庫的寫次數(shù)、優(yōu)化數(shù)據(jù)庫的讀操作、合理的利用緩存。

上邊是就是筆者想到的一些代碼優(yōu)化和架構(gòu)升級(jí)的方案。

結(jié)束語

不知道大家有沒有發(fā)現(xiàn),使用 Go 搭建一個(gè) IM 系統(tǒng)比使用其他語言要簡單很多,而且具備更好的拓展性和性能(并沒有吹噓 Go 的意思)。

在當(dāng)今這個(gè)時(shí)代,5G 將要普及,流量不再昂貴,IM 系統(tǒng)已經(jīng)廣泛滲入到了用戶日常生活中。

對(duì)于程序員來說,搭建一個(gè) IM 系統(tǒng)不再是困難的事情,如果讀者根據(jù)本文的思路,理解 WebSocket,Copy 代碼,運(yùn)行程序,應(yīng)該用不了半天的時(shí)間就能上手這樣一個(gè) IM 系統(tǒng)。

IM 系統(tǒng)是一個(gè)時(shí)代,從 QQ、微信到現(xiàn)在的人工智能,都廣泛應(yīng)用了即時(shí)通信,圍繞即時(shí)通信,又可以做更多產(chǎn)品布局。

筆者寫本文的目的就是想要幫助更多人了解 IM,幫助一些開發(fā)者快速的搭建一個(gè)應(yīng)用,燃起大家學(xué)習(xí)網(wǎng)絡(luò)編程知識(shí)的興趣,希望的讀者能有所收獲,能將 IM 系統(tǒng)應(yīng)用到更多的產(chǎn)品布局中。

GitHub 可下載查看源代碼:

https://github.com/GuoZhaoran/fastIM

作者:繪你一世傾城

編輯:陶家龍

出處:https://juejin.im/post/5e1b29366fb9a02fc31dda24

分享到:
標(biāo)簽:系統(tǒng) IM
用戶無頭像

網(wǎng)友整理

注冊(cè)時(shí)間:

網(wǎng)站:5 個(gè)   小程序:0 個(gè)  文章:12 篇

  • 51998

    網(wǎng)站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會(huì)員

趕快注冊(cè)賬號(hào),推廣您的網(wǎng)站吧!
最新入駐小程序

數(shù)獨(dú)大挑戰(zhàn)2018-06-03

數(shù)獨(dú)一種數(shù)學(xué)游戲,玩家需要根據(jù)9

答題星2018-06-03

您可以通過答題星輕松地創(chuàng)建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學(xué)四六

運(yùn)動(dòng)步數(shù)有氧達(dá)人2018-06-03

記錄運(yùn)動(dòng)步數(shù),積累氧氣值。還可偷

每日養(yǎng)生app2018-06-03

每日養(yǎng)生,天天健康

體育訓(xùn)練成績?cè)u(píng)定2018-06-03

通用課目體育訓(xùn)練成績?cè)u(píng)定