天天看點

基于go語言搭建高性能IM系統

1.時代的裡程碑——即時通信

前陣子看了《創業時代》,電視劇的劇情大概是這樣的:IT工程師郭鑫年與好友羅維與投行精英那藍等人一起,踏上網際網路創業之路。創業開發的是一款叫做“魔晶”的IM産品。郭鑫年在第一次創業失敗後,離了婚,還欠了很多外債,騎着自行車經曆了西藏一次生死訣别之後産生了靈感,想要創作一款IM産品“魔晶”,“魔晶”的初衷是為了增加人與人之間的感情,雖然劇情純屬虛構,但确實讓人浮想QQ當初的設想是不是就是這樣的呢?

有一點是可以确定的,即時通信确實是一個時代的裡程碑。騰訊的強大離不開兩款産品:QQ和微信,這兩款産品設計的思路是不一樣的,QQ依托于IM系統,為了打造個人空間、全民娛樂而設計,我們常常會看到QQ被初高中生喜愛,QQ賬号也往往與音樂、遊戲綁定在一起;微信從QQ導流以後,主打商業領域,從剛開始推出微信支付與支付寶競争,在商業支付領域占得了一席之地(微信支付主要被使用者用于小額支付場景,支付寶主要用在企業大額轉賬、個人金融理财領域)以後。微信又相繼推出了公衆号、小程式,很明顯在商業領域已經占據了支付寶的上風,成為了商業APP中的霸主,後來才有了聊天寶、多閃和馬桶三大門派圍攻微信的鬧劇,結果大家可能都知道了......

基于go語言搭建高性能IM系統

即使這樣,也不可低估了支付寶在商業領域的價值。和微信産品設計的初衷不同,支付寶更青睐于拓展功能自己來內建與實作,做的比較精緻。支付寶在支付安全性方面,做的比微信好很多,整個應用用起來也比微信要順暢,支付寶也有自己的小程式,不過往往都是和相關企業合作或打通接口來建立應用,比如生活繳費、餓了麼外賣、滴滴出行等等。而微信則更多将應用的建立權限開放給開發者,由企業開發者來建立小程式、維護公衆号,進而實作自己的商業價值,事實證明,微信很成功!

阿裡依托于IM系統進擊辦公領域,打造了“釘釘”,又是一款比較精緻的産品,其中打卡考勤、請假審批、會議管理都做的非常好,和微信不同的是,企業通過釘釘交流的資訊,對方是能看到資訊是否“已讀”的(畢竟是辦公,這個功能還是很有必要的)。騰訊也不甘示弱,建立“企業微信”,開始和“釘釘”正面交鋒,雖然在市場佔有率上還是落後于釘釘,但使用者增長很快。

基于go語言搭建高性能IM系統
釘釘于2015年1月正式上線,2016年4月騰訊正式釋出企業微信1.0版本,也隻有簡單的考勤、請假、報帳等功能,在産品功能上略顯平淡。彼時再看釘釘,憑借先發優勢,初期就确定的産品線“讨好”老闆,2016年企業數100萬,2018年這個數量上升到700萬,可見釘釘發展速度之快,穩固了釘釘在B端市場的地位。企業微信早期舉棋不定的打法,也讓它在企業OA辦公上玩不過釘釘。但企業微信在釋出3.0版本後,局面開始扭轉,釘釘在使用者數量上似乎已經飽和,難以有新的突破,而企業微信才真正開始逐漸占據市場。

依托于IM系統發展起來的企業還有陌陌、探探,相比較與微信來講,它們的功能更集中于交友和情感。(不知道這是不是人家企業每年年終都人手一部iphone的原因,開個玩笑)

筆者今年參加了一次Gopher大會,有幸聽探探的架構師分享了它們今年微服務化的過程,本文快速搭建的IM系統也是使用Go語言來快速實作的,這裡先和各位分享一下探探APP的架構圖:

基于go語言搭建高性能IM系統

以上講了一些IM系統的産品的設計,下邊我們回歸主題,大概說一下本文的章節内容安排。

2.章節概述

本文的目的是幫助讀者較為深入的了解socket協定,并快速搭建一個高可用、可拓展的IM系統(文章标題純屬引人眼球,不是真的,請讀者不要在意。),同時幫助讀者了解IM系統後續可以做哪些優化和改進。麻雀雖小,五髒俱全,該IM系統包含基本的注冊、登入、添加好友基礎功能,另外提供單聊、群聊,并且支援發送文字、表情和圖檔,在搭建的系統上,讀者可輕松的拓展語音、視訊聊天、發紅包等業務。為了幫助讀者更清楚的了解IM系統的原理,第3節我會專門深入講解一下websocket協定,websocket是長連結中比較常用的協定;然後第4節會講解快速搭建IM系統的技巧和主要代碼實作;在第5節筆者會對IM系統的架構更新和優化提出一些建議和思路;在最後章節做本文的回顧總結。

3.深入了解websocket協定

Web Sockets的目标是在一個單獨的持久連接配接上提供全雙工、雙向通信。在Javascript建立了Web Socket之後,會有一個HTTP請求發送到浏覽器以發起連接配接。在取得伺服器響應後,建立的連接配接會将HTTP更新從HTTP協定交換為WebSocket協定。由于WebSocket使用自定義的協定,是以URL模式也略有不同。未加密的連接配接不再是http://,而是ws://;加密的連接配接也不是https://,而是wss://。在使用WebSocket URL時,必須帶着這個模式,因為将來還有可能支援其他的模式。使用自定義協定而非HTTP協定的好處是,能夠在用戶端和伺服器之間發送非常少量的資料,而不必擔心HTTP那樣位元組級的開銷。由于傳遞的資料包很小,是以WebSocket非常适合移動應用。上文中隻是對Web Sockets進行了籠統的描述,接下來的篇幅會對Web Sockets的細節實作進行深入的探索,本文接下來的四個小節不會涉及到大量的代碼片段,但是會對相關的API和技術原理進行分析,相信大家讀完下文之後再來看這段描述,會有一種豁然開朗的感覺。

3.1 WebSocket複用了HTTP的握手通道

“握手通道”是HTTP協定中用戶端和服務端通過"TCP三向交握"建立的通信通道。用戶端和服務端使用HTTP協定進行的每次互動都需要先建立這樣一條“通道”,然後通過這條通道進行通信。我們熟悉的ajax互動就是在這樣一個通道上完成資料傳輸的,隻不過ajax互動是短連接配接,在一次request->response之後,“通道”連接配接就斷開了。下面是HTTP協定中建立“握手通道”的過程示意圖:

基于go語言搭建高性能IM系統

上文中我們提到:在Javascript建立了WebSocket之後,會有一個HTTP請求發送到浏覽器以發起連接配接,然後服務端響應,這就是“握手“的過程,在這個握手的過程當中,用戶端和服務端主要做了兩件事情:

  • 建立了一條連接配接“握手通道”用于通信(這點和HTTP協定相同,不同的是HTTP協定完成資料互動後就釋放了這條握手通道,這就是所謂的“短連接配接”,它的生命周期是一次資料互動的時間,通常是毫秒級别的。)
  • 将HTTP協定更新到WebSocket協定,并複用HTTP協定的握手通道,進而建立一條持久連接配接。說到這裡可能有人會問:HTTP協定為什麼不複用自己的“握手通道”,而非要在每次進行資料互動的時候都通過TCP三向交握重建立立“握手通道”呢?答案是這樣的:雖然“長連接配接”在用戶端和服務端互動的過程中省去了每次都建立“握手通道”的麻煩步驟,但是維持這樣一條“長連接配接”是需要消耗伺服器資源的,而在大多數情況下,這種資源的消耗又是不必要的,可以說HTTP标準的制定經過了深思熟慮的考量。到我們後邊說到WebSocket協定資料幀時,大家可能就會明白,維持一條“長連接配接”服務端和用戶端需要做的事情太多了。

說完了握手通道,我們再來看HTTP協定如何更新到WebSocket協定的。

3.2 HTTP協定更新為WebSocket協定

更新協定需要用戶端和服務端交流,服務端怎麼知道要将HTTP協定更新到WebSocket協定呢?它一定是接收到了用戶端發送過來的某種信号。下面是我從谷歌浏覽器中截取的“用戶端發起協定更新請求的封包”,通過分析這段封包,我們能夠得到有關WebSocket中協定更新的更多細節。

基于go語言搭建高性能IM系統

首先,用戶端發起協定更新請求。采用的是标準的HTTP封包格式,且隻支援GET方法。下面是重點請求的首部的意義:

  • Connection:Upgrade:表示要更新的協定
  • Upgrade: websocket:表示要更新到websocket協定
  • Sec-WebSocket-Version: 13:表示websocket的版本
  • Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg== :與Response Header中的響應首部Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY=是配套的,提供基本的防護,比如惡意的連接配接或者無意的連接配接。其中Connection就是我們前邊提到的,用戶端發送給服務端的信号,服務端接受到信号之後,才會對HTTP協定進行更新。那麼服務端怎樣确認用戶端發送過來的請求是否是合法的呢?在用戶端每次發起協定更新請求的時候都會産生一個唯一碼:Sec-WebSocket-Key。服務端拿到這個碼後,通過一個算法進行校驗,然後通過Sec-WebSocket-Accept響應給用戶端,用戶端再對Sec-WebSocket-Accept進行校驗來完成驗證。這個算法很簡單:
  1. 将Sec-WebSocket-Key跟全局唯一的(GUID,[RFC4122])辨別:258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接
  2. 通過SHA1計算出摘要,并轉成base64字元串

258EAFA5-E914-47DA-95CA-C5AB0DC85B11這個字元串又叫“魔串",至于為什麼要使用它作為Websocket握手計算中使用的字元串,這點我們無需關心,隻需要知道它是RFC标準規定就可以了,官方的解析也隻是簡單的說此值不大可能被不明白WebSocket協定的網絡終端使用。我們還是用世界上最好的語言來描述一下這個算法吧。

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

服務端響應用戶端的頭部資訊和HTTP協定的格式是相同的,HTTP1.1協定是以換行符(\r\n)分割的,我們可以通過正則比對解析出Sec-WebSocket-Accept的值,這和我們使用curl工具模拟get請求是一個道理。這樣展示結果似乎不太直覺,我們使用指令行CLI來根據上圖中的Sec-WebSocket-Key和握手算法來計算一下服務端傳回的Sec-WebSocket-Accept是否正确:

基于go語言搭建高性能IM系統

從圖中可以看到,通過算法算出來的base64字元串和Sec-WebSocket-Accept是一樣的。那麼假如服務端在握手的過程中傳回一個錯誤的Sec-WebSocket-Accept字元串會怎麼樣呢?當然是用戶端會報錯,連接配接會建立失敗,大家可以嘗試一下,例如将全局唯一辨別符258EAFA5-E914-47DA-95CA-C5AB0DC85B11改為258EAFA5-E914-47DA-95CA-C5AB0DC85B12。

3.3 WebSocket的幀和資料分片傳輸

下圖是我做的一個測試:将小說《飄》的第一章内容複制成文本資料,通過用戶端發送到服務端,然後服務端響應相同的資訊完成了一次通信。

基于go語言搭建高性能IM系統

可以看到一篇足足有将近15000位元組的資料在用戶端和服務端完成通信隻用了150ms的時間。我們還可以看到浏覽器控制台中frame欄中顯示的用戶端發送和服務端響應的文本資料,你一定驚訝WebSocket通信強大的資料傳輸能力。資料是否真的像frame中展示的那樣用戶端直接将一大篇文本資料發送到服務端,服務端接收到資料之後,再将一大篇文本資料傳回給用戶端呢?這當然是不可能的,我們都知道HTTP協定是基于TCP實作的,HTTP發送資料也是分包轉發的,就是将大資料根據封包形式分割成一小塊一小塊發送到服務端,服務端接收到用戶端發送的封包後,再将小塊的資料拼接組裝。關于HTTP的分包政策,大家可以檢視相關資料進行研究,websocket協定也是通過分片打包資料進行轉發的,不過政策上和HTTP的分包不一樣。frame(幀)是websocket發送資料的基本機關,下邊是它的封包格式:

基于go語言搭建高性能IM系統

封包内容中規定了資料标示,操作代碼、掩碼、資料、資料長度等格式。不太了解沒關系,下面我通過講解大家隻要了解封包中重要标志的作用就可以了。首先我們明白了用戶端和服務端進行Websocket消息傳遞是這樣的:

  • 用戶端:将消息切割成多個幀,并發送給服務端。
  • 服務端:接收消息幀,并将關聯的幀重新組裝成完整的消息。

服務端在接收到用戶端發送的幀消息的時候,将這些幀進行組裝,它怎麼知道何時資料組裝完成的呢?這就是封包中左上角FIN(占一個比特)存儲的資訊,1表示這是消息的最後一個分片(fragment)如果是0,表示不是消息的最後一個分片。websocket通信中,用戶端發送資料分片是有序的,這一點和HTTP不一樣,HTTP将消息分包之後,是并發無序的發送給服務端的,包資訊在資料中的位置則在HTTP封包中存儲,而websocket僅僅需要一個FIN比特位就能保證将資料完整的發送到服務端。接下來的RSV1,RSV2,RSV3三個比特位的作用又是什麼呢?這三個标志位是留給用戶端開發者和服務端開發者開發過程中協商進行拓展的,預設是0。拓展如何使用必須在握手的階段就協商好,其實握手本身也是用戶端和服務端的協商。

3.4 Websocket連接配接保持和心跳檢測

Websocket是長連接配接,為了保持用戶端和服務端的實時雙向通信,需要確定用戶端和服務端之間的TCP通道保持連接配接沒有斷開。但是對于長時間沒有資料往來的連接配接,如果依舊保持着,可能會浪費服務端資源。但是不排除有些場景,用戶端和服務端雖然長時間沒有資料往來,仍然需要保持連接配接,就比如說你幾個月沒有和一個QQ好友聊天了,突然有一天他發QQ消息告訴你他要結婚了,你還是能在第一時間收到。那是因為,用戶端和服務端一直再采用心跳來檢查連接配接。用戶端和服務端的心跳連接配接檢測就像打乒乓球一樣:

  • 發送方->接收方:ping
  • 接收方->發送方:pong

等什麼時候沒有ping、pong了,那麼連接配接一定是存在問題了。說了這麼多,接下來我使用Go語言來實作一個心跳檢測,Websocket通信實作細節是一件繁瑣的事情,直接使用開源的類庫是比較不錯的選擇,我使用的是:gorilla/websocket。這個類庫已經将websocket的實作細節(握手,資料解碼)封裝的很好啦。下面我就直接貼代碼了:

package main


import (
    "net/http"
    "time"


    "github.com/gorilla/websocket"
)


var (
    //完成握手操作
    upgrade = websocket.Upgrader{
       //允許跨域(一般來講,websocket都是獨立部署的)
       CheckOrigin:func(r *http.Request) bool {
            return true
       },
    }
)


func wsHandler(w http.ResponseWriter, r *http.Request) {
   var (
         conn *websocket.Conn
         err error
         data []byte
   )
   //服務端對用戶端的http請求(更新為websocket協定)進行應答,應答之後,協定更新為websocket,http建立連接配接時的tcp三次握手将保持。
   if conn, err = upgrade.Upgrade(w, r, nil); err != nil {
        return
   }


    //啟動一個協程,每隔1s向用戶端發送一次心跳消息
    go func() {
        var (
            err error
        )
        for {
            if err = conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err != nil {
                return
            }
            time.Sleep(1 * time.Second)
        }
    }()


   //得到websocket的長連結之後,就可以對用戶端傳遞的資料進行操作了
   for {
         //通過websocket長連結讀到的資料可以是text文本資料,也可以是二進制Binary
        if _, data, err = conn.ReadMessage(); err != nil {
            goto ERR
     }
     if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
         goto ERR
     }
   }
ERR:
    //出錯之後,關閉socket連接配接
    conn.Close()
}


func main() {
    http.HandleFunc("/ws", wsHandler)
    http.ListenAndServe("0.0.0.0:7777", nil)
}      

借助go語言很容易搭建協程的特點,我專門開啟了一個協程每秒向用戶端發送一條消息。打開用戶端浏覽器可以看到,frame中每秒的心跳資料一直在跳動,當長連結斷開之後,心跳就沒有了,就像人沒有了心跳一樣:

基于go語言搭建高性能IM系統

大家對websocket協定已經有了了解,接下來就讓我們一起快速搭建一個高性能、可拓展的IM系統吧。

4.快速搭建高性能、可拓展的IM系統

4.1 系統架構和代碼檔案目錄結構

下圖是一個比較完備的IM系統架構:包含了C端、接入層(通過協定接入)、S端處理邏輯和分發消息、存儲層用來持久化資料。

基于go語言搭建高性能IM系統

我們本節C端使用的是Webapp, 通過Go語言渲染Vue模版快速實作功能;接入層使用的是websocket協定,前邊已經進行了深入的介紹;S端是我們實作的重點,其中鑒權、登入、關系管理、單聊和群聊的功能都已經實作,讀者可以在這部分功能的基礎上再拓展其他的功能,比如:視訊語音聊天、發紅包、朋友圈等業務子產品;存儲層我們做的比較簡單,隻是使用Mysql簡單持久化存儲了使用者關系,然後聊天中的圖檔資源我們存儲到了本地檔案中。雖然我們的IM系統實作的比較簡化,但是讀者可以在次基礎上進行改進、完善、拓展,依然能夠作出高可用的企業級産品。

我們的系統服務使用Go語言建構,代碼結構比較簡潔,但是性能比較優秀(這是Java和其他語言所無法比拟的),單機支援幾萬人的線上聊天。

下邊是代碼檔案的目錄結構:

app
│   ├── args
│   │   ├── contact.go
│   │   └── pagearg.go
│   ├── controller           //控制器層,api入口
│   │   ├── chat.go
│   │   ├── contract.go
│   │   ├── upload.go
│   │   └── user.go
│   ├── main.go             //程式入口
│   ├── model               //資料定義與存儲
│   │   ├── community.go
│   │   ├── contract.go
│   │   ├── init.go
│   │   └── user.go
│   ├── service             //邏輯實作
│   │   ├── contract.go
│   │   └── user.go
│   ├── util                //幫助函數    
│   │   ├── md5.go
│   │   ├── parse.go
│   │   ├── resp.go
│   │   └── string.go
│   └── view                //模版資源
│   │   ├── ...
asset                       //js、css檔案
resource                    //上傳資源,上傳圖檔會放到這裡      

從入口函數main.go開始,我們定義了controller層,是用戶端api的入口。service用來處理主要的使用者邏輯,消息分發、使用者管理都在這裡實作。model層定義了一些資料表,主要是使用者注冊和使用者好友關系、群組等資訊,存儲到mysql。util包下是一些幫助函數,比如加密、請求響應等。view下邊存儲了模版資源資訊,上邊所說的這些都在app檔案夾下存儲,外層還有asset用來存儲css、js檔案和聊天中會用到的表情圖檔等。resource下存儲使用者聊天中的圖檔或者視訊等檔案。總體來講,我們的代碼目錄機構還是比較簡潔清晰的。

了解了我們要搭建的IM系統架構,我們再來看一下架構重點實作的功能吧。

4.2 10行代碼萬能模版渲染

Go語言提供了強大的html渲染能力,非常簡單的建構web應用,下邊是實作模版渲染的代碼,它太簡單了,以至于可以直接在main.go函數中實作:

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實作靜态資源伺服器也很簡單,隻需要調用http.FileServer就可以了,這樣html檔案就可以很輕松的通路依賴的js、css和圖示檔案了。使用http/template包下的ParseGlob、ExecuteTemplate又可以很輕松的解析web頁面,這些工作完全不依賴與nginx。現在我們就完成了登入、注冊、聊天C端界面的建構工作:

基于go語言搭建高性能IM系統
基于go語言搭建高性能IM系統

4.3 注冊、登入和鑒權

之前我們提到過,對于注冊、登入和好友關系管理,我們需要有一張​

​user​

​​表來存儲使用者資訊。我們使用​

​github.com/go-xorm/xorm​

​​來操作​

​mysql​

​​,首先看一下​

​mysql​

​表的設計:

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"`   //使用者鑒權
  Memo      string  `xorm:"varchar(140)" form:"memo" json:"memo"`
  Createat   time.Time  `xorm:"datetime" form:"createat" json:"createat"`   //建立時間, 統計使用者增量時使用
}      

我們​

​user​

​表中存儲了使用者名、密碼、頭像、使用者性别、手機号等一些重要的資訊,比較重要的是我們也存儲了token标示使用者在使用者登入之後,http協定更新為websocket協定進行鑒權,這個細節點我們前邊提到過,下邊會有代碼示範。接下來我們看一下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)
  //設定資料庫連接配接數
  DbEngine.SetMaxOpenConns(10)
  //自動建立資料庫
  DbEngine.Sync(new(User), new(Community), new(Contact))


  fmt.Println("init database ok!")
}      

我們建立一個DbEngine全局mysql連接配接對象,設定了一個大小為10的連接配接池。model包裡的init函數在程式加載的時候會先執行,對Go語言熟悉的同學應該知道這一點。我們還設定了一些額外的參數用于調試程式,比如:設定列印運作中的sql,自動的同步資料表等,這些功能在生産環境中可以關閉。我們的model初始化工作就做完了,非常簡陋,在實際的項目中,像資料庫的使用者名、密碼、連接配接數和其他的配置資訊,建議設定到配置檔案中,然後讀取,而不像本文寫死的程式中。

注冊是一個普通的api程式,對于Go語言來說,完成這件工作太簡單了,我們來看一下代碼:

############################
//app/controller/user.go
############################
......
//使用者注冊
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{}


//使用者注冊
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(&registerUser)
    if err != nil {
      return registerUser, err
  }
  //如果使用者已經注冊,傳回錯誤資訊
  if registerUser.Id > 0 {
    return registerUser, errors.New("該手機号已注冊")
  }


  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(&registerUser)


  return registerUser,  err
}
......
############################
//main.go
############################
......
func main() {
    http.HandleFunc("/user/register", controller.UserRegister)
}      

​​首先我們使用util.Bind(request, &user)将使用者參數綁定到user對象上,使用的是util包中的Bind函數,具體實作細節讀者可以自行研究,主要模仿了Gin架構的參數綁定,可以拿來即用,非常友善。然後我們根據使用者手機号搜尋資料庫中是否已經存在,如果不存在就插入到資料庫中,傳回注冊成功資訊,邏輯非常簡單。登入邏輯更簡單:​​

############################
//app/controller/user.go
############################
...
//使用者登入
func UserLogin(writer http.ResponseWriter, request *http.Request) {
  request.ParseForm()


  mobile := request.PostForm.Get("mobile")
  plainpwd := request.PostForm.Get("passwd")


  //校驗參數
  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) {
  //資料庫操作
  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)
}      

​​實作了登入邏輯,接下來我們就到了使用者首頁,這裡列出了使用者清單,點選即可進入聊天頁面。使用者也可以點選下邊的tab欄檢視自己所在的群組,可以由此進入群組聊天頁面。具體這些工作還需要讀者自己開發使用者清單、添加好友、建立群組、添加群組等功能,這些都是一些普通的api開發工作,我們的代碼程式中也實作了,讀者可以拿去修改使用,這裡就不再示範了。我們再重點看一下使用者鑒權這一塊吧,使用者鑒權是指使用者點選聊天進入聊天界面時,用戶端會發送一個GET請求給服務端,請求建立一條websocket長連接配接,服務端收到建立連接配接的請求之後,會對用戶端請求進行校驗,以确實是否建立長連接配接,然後将這條長連接配接的句柄添加到map當中(因為服務端不僅僅對一個用戶端服務,可能存在千千萬萬個長連接配接)維護起來。我們下邊來看具體代碼實作:​​

############################
//app/controller/chat.go
############################
......
//本核心在于形成userid和Node的映射關系
type Node struct {
  Conn *websocket.Conn
  //并行轉串行,
  DataQueue chan []byte
  GroupSets set.Interface
}
......
//userid和Node映射關系表
var clientMap map[int64]*Node = make(map[int64]*Node, 0)
//讀寫鎖
var rwlocker sync.RWMutex
//實作聊天的功能
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)
  //校驗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()


  //開啟協程處理發送邏輯
  go sendproc(node)


  //開啟協程完成接收邏輯
  go recvproc(node)


  sendMsg(userId, []byte("welcome!"))
}


......


//校驗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)
}
......      

​​進入聊天室,用戶端發起/chat的GET請求,服務端首先建立了一個Node結構體,用來存儲和用戶端建立起來的websocket長連接配接句柄,每一個句柄都有一個管道DataQueue,用來收發資訊,GroupSets是用戶端對應的群組資訊,後邊我們會提到。​​

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

服務端建立了一個map,将用戶端使用者id和其​

​Node​

​關聯起來:

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

接下來是主要的使用者邏輯了,服務端接收到用戶端的參數之後,首先校驗token是否合法,由此确定是否要更新http協定到websocket協定,建立長連接配接,這一步稱為鑒權。

//校驗token是否合法
  islegal := checkToken(userId, token)


  conn, err := (&websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
      return islegal
    },
  }).Upgrade(writer, request, nil)      

​​鑒權成功以後,服務端初始化一個Node,搜尋該用戶端使用者所在的群組id,填充到群組的GroupSets屬性中。然後将Node節點添加到ClientMap中維護起來,我們對ClientMap的操作一定要加鎖,因為Go語言在并發情況下,對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()      

服務端和用戶端建立了長連結之後,會開啟兩個協程專門來處理用戶端消息的收發工作,對于Go語言來說,維護協程的代價是很低的,是以說我們的單機程式可以很輕松的支援成千上完的使用者聊天,這還是在沒有優化的情況下。

......
//開啟協程處理發送邏輯
  go sendproc(node)


  //開啟協程完成接收邏輯
  go recvproc(node)


  sendMsg(userId, []byte("welcome!"))
......      

至此,我們的鑒權工作也已經完成了,用戶端和服務端的連接配接已經建立好了,接下來我們就來實作具體的聊天功能吧。

4.4 實作單聊和群聊

實作聊天的過程中,消息體的設計至關重要,消息體設計的合理,功能拓展起來就非常的友善,後期維護、優化起來也比較簡單。我們先來看一下,我們消息體的設計:

############################
//app/controller/chat.go
############################
type Message struct {
  Id      int64  `json:"id,omitempty" form:"id"`           //消息ID
  Userid  int64  `json:"userid,omitempty" form:"userid"`   //誰發的
  Cmd     int    `json:"cmd,omitempty" form:"cmd"`         //群聊還是私聊
  Dstid   int64  `json:"dstid,omitempty" form:"dstid"`     //對端使用者ID/群ID
  Media   int    `json:"media,omitempty" form:"media"`     //消息按照什麼樣式展示
  Content string `json:"content,omitempty" form:"content"` //消息的内容
  Pic     string `json:"pic,omitempty" form:"pic"`         //預覽圖檔
  Url     string `json:"url,omitempty" form:"url"`         //服務的URL
  Memo    string `json:"memo,omitempty" form:"memo"`       //簡單描述
  Amount  int    `json:"amount,omitempty" form:"amount"`   //其他和數字相關的
}      

​​每一條消息都有一個唯一的id,将來我們可以對消息持久化存儲,但是我們系統中并沒有做這件工作,讀者可根據需要自行完成。然後是userid,發起消息的使用者,對應的是dstid,要将消息發送給誰。還有一個參數非常重要,就是cmd,它表示是群聊還是私聊,群聊和私聊的代碼處理邏輯有所差別,我們為此專門定義了一些cmd常量:​​

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

​media​

​​是媒體類型,我們都知道微信支援語音、視訊和各種其他的檔案傳輸,我們設定了該參數之後,讀者也可以自行拓展這些功能。​

​content​

​​是消息文本,是聊天中最常用的一種形式。​

​pic​

​​和​

​url​

​​是為圖檔和其他連結資源所設定的。​

​memo​

​​是簡介,​

​amount​

​是和數字相關的資訊,比如說發紅包業務有可能使用到該字段。

消息體的設計就是這樣,基于此消息體,我們來看一下,服務端如何收發消息,實作單聊和群聊吧。還是從上一節說起,我們為每一個用戶端長連結開啟了兩個協程,用于收發消息,聊天的邏輯就在這兩個協程當中實作。

############################
//app/controller/chat.go
############################
......
//發送邏輯
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對data進一步處理
    fmt.Printf("recv<=%s", data)
  }
}
......
//後端排程邏輯處理
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
  }
}
......      

服務端向用戶端發送消息邏輯比較簡單,就是将用戶端發送過來的消息,直接添加到目标使用者​

​Node​

​​的channel中去就好了。通過websocket的​

​WriteMessage​

​就可以實作此功能:

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
      }
    }
  }
}      

收發邏輯是這樣的,服務端通過websocket的​

​ReadMessage​

​​方法接收到使用者資訊,然後通過​

​dispatch​

​方法進行排程:

func recvproc(node *Node) {
  for {
    _, data, err := node.Conn.ReadMessage()
    if err != nil {
      log.Println(err.Error())
      return
    }


    dispatch(data)
    //todo對data進一步處理
    fmt.Printf("recv<=%s", data)
  }
}      

​dispatch​

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

  • 解析消息體到Message中
  • 根據消息類型,将消息體添加到不同使用者或者使用者組的channel當中

Go語言中的channel是協程間通信的強大工具, ​

​dispatch​

​​隻要将消息添加到channel當中,發送協程就會擷取到資訊發送給用戶端,這樣就實作了聊天功能,單聊和群聊的差別隻是服務端将消息發送給群組還是個人,如果發送給群組,程式會周遊整個​

​clientMap​

​​, 看看哪個使用者在這個群組當中,然後将消息發送。其實更好的實踐是我們再維護一個群組和使用者關系的Map,這樣在發送群組消息的時候,取得使用者資訊就比周遊整個​

​clientMap​

​代價要小很多了。

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,我們實作使用者聊天功能還是非常友善的,代碼可讀性很強,建構的程式也很健壯。下邊是筆者本地聊天的示意圖:

基于go語言搭建高性能IM系統
基于go語言搭建高性能IM系統

4.5 發送表情和圖檔

下邊我們再來看一下聊天中經常使用到的發送表情和圖檔功能是如何實作的吧。其實表情也是小圖檔,隻是和聊天中圖檔不同的是,表情圖檔比較小,可以緩存在用戶端,或者直接存放到用戶端代碼的代碼檔案中(不過現在微信聊天中有的表情包都是通過網絡傳輸的)。下邊是一個聊天中傳回的圖示文本資料:

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

用戶端拿到url後,就加載本地的小圖示。聊天中使用者發送圖檔也是一樣的原理,不過聊天中使用者的圖檔需要先上傳到伺服器,然後服務端傳回url,用戶端再進行加載,我們的IM系統也支援此功能,我們看一下圖檔上傳的程式:

############################
//app/controller/upload.go
############################
func init() {
  os.MkdirAll("./resource", os.ModePerm)
}


func FileUpload(writer http.ResponseWriter, request *http.Request) {
  UploadLocal(writer, request)
}


//将檔案存儲在本地/im_resource目錄下
func UploadLocal(writer http.ResponseWriter, request *http.Request) {
  //獲得上傳源檔案
  srcFile, head, err := request.FormFile("file")
  if err != nil {
    util.RespFail(writer, err.Error())
  }
  //建立一個新的檔案
  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)
  //建立檔案
  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)
}      

​​我們将檔案存放到本地的一個磁盤檔案夾下,然後發送給用戶端路徑,用戶端通過路徑加載相關的圖檔資訊。​​

​​關于發送圖檔,我們雖然實作功能,但是做的太簡單了,我們在接下來的章節詳細的和大家探讨一下系統優化相關的方案。怎樣讓我們的系統在生産環境中用的更好。​​

基于go語言搭建高性能IM系統

5. 程式優化和系統架構更新方案

基于go語言搭建高性能IM系統

我們上邊實作了一個功能健全的IM系統,要将該系統應用在企業的生産環境中,需要對代碼和系統架構做優化,才能實作真正的高可用。本節主要從代碼優化和架構更新上談一些個人觀點,能力有限不可能面面俱到,希望讀者也在評論區給出更多好的建議。​​ThinkPHP5.0結合Swoole開發WebSocket線上聊天​​

5.1 代碼優化

​​我們的代碼沒有使用架構,函數和api都寫的比較簡陋,雖然進行了簡單的結構化,但是很多邏輯并沒有解耦,是以建議大家業界比較成熟的架構對代碼進行重構,Gin就是一個不錯的選擇。​​

​​系統程式中使用clientMap來存儲用戶端長連結資訊,Go語言中對于大Map的讀寫要加鎖,有一定的性能限制,在使用者量特别大的情況下,讀者可以對clientMap做拆分,根據使用者id做hash或者采用其他的政策,也可以将這些長連結句柄存放到redis中。​​

​​上邊提到圖檔上傳的過程,有很多可以優化的地方,首先是圖檔壓縮(微信也是這樣做的),圖檔資源的壓縮不僅可以加快傳輸速度,還可以減少服務端存儲的空間。另外對于圖檔資源來說,實際上服務端隻需要存儲一份資料就夠了,讀者可以在圖檔上傳的時候做hash校驗,如果資源檔案已經存在了,就不需要再次上傳了,而是直接将url傳回給用戶端(各大網盤廠商的妙傳功能就是這樣實作的)​​

​​代碼還有很多優化的地方,比如我們可以将鑒權做的更好,使用wss://代替ws://,在一些安全領域,可以對消息體進行加密,在高并發領域,可以對消息體進行壓縮;對Mysql連接配接池再做優化,将消息持久化存儲到mongo,避免對資料庫頻繁的寫入,将單條寫入改為多條一塊寫入;為了使程式耗費更少的Cpu,降低對消息體進行Json編碼的次數,一次編碼,多次使用......​​

5.2 系統架構更新

我們的系統太過于簡單,所在在架構更新上,有太多的工作可以做,筆者在這裡隻提幾點比較重要的:

  • 應用/資源服務分離

我們所說的資源指的是圖檔、視訊等檔案,可以選擇成熟廠商的Cos,或者自己搭建檔案伺服器也是可以的,如果資源量比較大,使用者比較廣,cdn是不錯的選擇。

  • 突破系統連接配接數,搭建分布式環境

​​對于伺服器的選擇,一般會選擇linux,linux下一切皆檔案,長連結也是一樣。單機的系統連接配接數是有限制的,一般來說能達到10萬就很不錯了,是以在使用者量增長到一定程式,需要搭建分布式。分布式的搭建就要優化程式,因為長連結句柄分散到不同的機器,實作消息廣播和分發是首先要解決的問題,筆者這裡不深入闡述了,一來是沒有足夠的經驗,二來是解決方案有太多的細節需要探讨。搭建分布式環境所面臨的問題還有:怎樣更好的彈性擴容、應對突發事件等。​​

  • 業務功能分離

我們上邊将使用者注冊、添加好友等功能和聊天功能放到了一起,真實的業務場景中可以将它們做分離,将使用者注冊、添加好友、建立群組放到一台伺服器上,将聊天功能放到另外的伺服器上。業務的分離不僅使功能邏輯更加清晰,還能更有效的利用伺服器資源。

  • 減少資料庫I/O,合理利用緩存

​​我們的系統沒有将消息持久化,使用者資訊持久化到mysql中去。在業務當中,如果要對消息做持久化儲存,就要考慮資料庫I/O的優化,簡單講:合并資料庫的寫次數、優化資料庫的讀操作、合理的利用緩存。​​