首頁>技術>

本文為開源工程:“github.com/GuoZhaoran/fastIM”的配套文章,原作者:“繪你一世傾城”,現為:獵豹移動php開發工程師,感謝原作者的技術分享。

0、引言

站長提示:本文適合有一定網路通訊技術基礎的IM新手閱讀。如果你對網路程式設計,以及IM的一些理論知識知之甚少,請務必首先閱讀:《新手入門一篇就夠:從零開發移動端IM》,按需補充相關知識。

配套原始碼:本文寫的雖然有點淺顯但涉及內容不少,建議結合程式碼一起來讀,文章配套的完整原始碼 請從本文文末 “11、完整原始碼下載” 處下載!

本站的另幾篇同類程式碼你可能也喜歡:

《自已開發IM有那麼難嗎?手把手教你自擼一個Andriod版簡易IM (有原始碼)》

《拿起鍵盤就是幹:跟我一起徒手開發一套分散式IM系統》

《適合新手:從零開發一個IM服務端(基於Netty,有完整原始碼)》

另外:本文作者的另一篇文章,有興趣也可以關注一下:《12306搶票帶來的啟示:看我如何用Go實現百萬QPS的秒殺系統(含原始碼)》。

1、正文概述

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

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

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

企業微信於2016年4月釋出1.0版本,也只有簡單的考勤、請假、報銷等功能,在產品功能上略顯平淡。彼時再看釘釘,憑藉先發優勢,初期就確定的產品線“討好”老闆,2016年企業數100萬,2018年這個數量上升到700萬,可見釘釘發展速度之快,穩固了釘釘在B端市場的地位。企業微信早期舉棋不定的打法,也讓它在企業OA辦公上玩不過釘釘。但企業微信在釋出3.0版本後,局面開始扭轉,釘釘在使用者數量上似乎已經飽和,難以有新的突破,而企業微信才真正開始逐漸佔據市場。

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

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

以上講了一些IM系統的產品方面不著邊際的廢話,下邊我們迴歸主題,大概說一下本文的章節內容安排。

本文的目的是幫助讀者較為深入的理解socket協議,並快速搭建一個高可用、可拓展的IM系統。同時幫助讀者了解IM系統後續可以做哪些優化和改進。

本文的內容概述:

1)本文演示的IM系統包含基本的註冊、登入、新增好友基礎功能;

2)提供單聊、群聊,並且支援傳送文字、表情和圖片,在搭建的系統上,讀者可輕鬆的拓展語音、視訊聊天、發紅包等業務。

2)為了幫助讀者更清楚的理解IM系統的原理,第3節我會專門深入講解一下websocket協議,websocket是長連結中比較常用的協議;

3)然後第4節會講解快速搭建IM系統的技巧和主要程式碼實現;

4)在第5節筆者會對IM系統的架構升級和優化提出一些建議和思路;

5)在最後章節做本文的回顧總結。

2、相關文章

更多實踐性程式碼參考:

《開源移動端IM技術框架MobileIMSDK》(* 推薦)

《自已開發IM有那麼難嗎?手把手教你自擼一個Andriod版簡易IM (有原始碼)》

《適合新手:從零開發一個IM服務端(基於Netty,有完整原始碼)》

《拿起鍵盤就是幹:跟我一起徒手開發一套分散式IM系統》

《一種Android端IM智慧心跳演算法的設計與實現探討(含樣例程式碼)》

《正確理解IM長連線的心跳及重連機制,並動手實現(有完整IM原始碼)》

《手把手教你用Netty實現網路通訊程式的心跳機制、斷線重連機制》

《NIO框架入門(一):服務端基於Netty4的UDP雙向通訊Demo演示 [附件下載]》

《NIO框架入門(二):服務端基於MINA2的UDP雙向通訊Demo演示 [附件下載]》

《NIO框架入門(三):iOS與MINA2、Netty4的跨平臺UDP雙向通訊實戰 [附件下載]》

《NIO框架入門(四):Android與MINA2、Netty4的跨平臺UDP雙向通訊實戰 [附件下載]》

《一個WebSocket實時聊天室Demo:基於node.js+socket.io [附件下載]》

相關IM架構方面的文章:

《淺談IM系統的架構設計》

《簡述移動端IM開發的那些坑:架構設計、通訊協議和客戶端》

《一套海量線上使用者的移動端IM架構設計實踐分享(含詳細圖文)》

《從零到卓越:京東客服即時通訊系統的技術架構演進歷程》

《蘑菇街即時通訊/IM伺服器開發之架構選擇》

《一套高可用、易伸縮、高併發的IM群聊、單聊架構方案設計實踐》

3、深入理解websocket協議3.1 簡介

WebSocket的目標是在一個單獨的持久連線上提供全雙工、雙向通訊。在Javascript建立了Web Socket之後,會有一個HTTP請求傳送到瀏覽器以發起連線。在取得伺服器響應後,建立的連線會將HTTP升級從HTTP協議交換為WebSocket協議。

由於WebSocket使用自定義的協議,所以URL模式也略有不同。未加密的連線不再是http://,而是ws://;加密的連線也不是https://,而是wss://。在使用WebSocket URL時,必須帶著這個模式,因為將來還有可能支援其他的模式。

使用自定義協議而非HTTP協議的好處是,能夠在客戶端和伺服器之間傳送非常少量的資料,而不必擔心HTTP那樣位元組級的開銷。由於傳遞的資料包很小,所以WebSocket非常適合移動應用。

接下來的篇幅會對Web Sockets的細節實現進行深入的探索,本文接下來的四個小節不會涉及到大量的程式碼片段,但是會對相關的API和技術原理進行分析,相信大家讀完下文之後再來看這段描述,會有一種豁然開朗的感覺。

即時通訊網有大量關於Web端即時通訊技術的文章,以下目錄可供你係統地學習和了解。

Web即時通訊新手入門貼:

《新手入門貼:詳解Web端即時通訊技術的原理》

Web端即時通訊技術盤點請參見:

《Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》

關於Ajax短輪詢:

找這方面的資料沒什麼意義,除非忽悠客戶,否則請考慮其它3種方案即可。

有關Comet技術的詳細介紹請參見:

《Comet技術詳解:基於HTTP長連線的Web端實時通訊技術》

《WEB端即時通訊:HTTP長連線、長輪詢(long polling)詳解》

《WEB端即時通訊:不用WebSocket也一樣能搞定訊息的即時性》

《開源Comet伺服器iComet:支援百萬併發的Web端即時通訊方案》

更多WebSocket的詳細介紹請參見:

《新手快速入門:WebSocket簡明教程》

《WebSocket詳解(一):初步認識WebSocket技術》

《WebSocket詳解(二):技術原理、程式碼演示和應用案例》

《WebSocket詳解(三):深入WebSocket通訊協議細節》

《WebSocket詳解(四):刨根問底HTTP與WebSocket的關係(上篇)》

《WebSocket詳解(五):刨根問底HTTP與WebSocket的關係(下篇)》

《WebSocket詳解(六):刨根問底WebSocket與Socket的關係》

《理論聯絡實際:從零理解WebSocket的通訊原理、協議格式、安全性》

《Socket.IO介紹:支援WebSocket、用於WEB端的即時通訊的框架》

《socket.io和websocket 之間是什麼關係?有什麼區別?》

有關SSE的詳細介紹文章請參見:

《SSE技術詳解:一種全新的HTML5伺服器推送事件技術》

4、開始動手,快速搭建高效能、可拓展的IM系統4.1 系統架構和程式碼檔案目錄結構

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

簡要介紹一下本次IM的技術實現情況:

1)我們本節C端使用的是Webapp, 通過Go語言渲染Vue模版快速實現功能;

2)接入層使用的是websocket協議,前邊已經進行了深入的介紹;

3)S端是我們實現的重點,其中鑑權、登入、關係管理、單聊和群聊的功能都已經實現,讀者可以在這部分功能的基礎上再拓展其他的功能,比如:視訊語音聊天、發紅包、朋友圈等業務模組;

4)儲存層我們做的比較簡單,只是使用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 //上傳資源,上傳圖片會放到這裡

原始碼的具體說明如下:

1)從入口函式main.go開始,我們定義了controller層,是客戶端api的入口;

2)service用來處理主要的使用者邏輯,訊息分發、使用者管理都在這裡實現;

3)model層定義了一些資料表,主要是使用者註冊和使用者好友關係、群組等資訊,儲存到mysql;

4)util包下是一些幫助函式,比如加密、請求響應等;

5)view下邊儲存了模版資源資訊,上邊所說的這些都在app資料夾下儲存;

6)外層還有asset用來儲存css、js檔案和聊天中會用到的表情圖片等;

7)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 := rangetpl.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端介面的構建工作:

4.3 註冊、登入和鑑權

之前我們提到過,對於註冊、登入和好友關係管理,我們需要有一張user表來儲存使用者資訊。我們使用https://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"

)

varDbEngine *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(®isterUser)

if err != nil{

returnregisterUser, 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(®isterUser)

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{

returnislegal

},

}).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 := rangecomIds {

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是客戶端對應的群組資訊,後邊我們會提到。

typeNode 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 := rangecomIds {

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 := rangeclientMap {

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方法所做的工作有兩件:

1)解析訊息體到Message中;

2)根據訊息型別,將訊息體新增到不同使用者或者使用者組的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 := rangeclientMap {

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,我們實現使用者聊天功能還是非常方便的,程式碼可讀性很強,構建的程式也很健壯。

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

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)

}

我們將檔案存放到本地的一個磁碟資料夾下,然後傳送給客戶端路徑,客戶端通過路徑載入相關的圖片資訊。

關於傳送圖片,我們雖然實現功能,但是做的太簡單了,我們在接下來的章節詳細的和大家探討一下系統優化相關的方案。怎樣讓我們的系統在生產環境中用的更好。

5、程式優化和系統架構升級方案

我們上邊實現了一個功能健全的IM系統,要將該系統應用在企業的生產環境中,需要對程式碼和系統架構做優化,才能實現真正的高可用。

本節主要從程式碼優化和架構升級上談一些個人觀點,能力有限不可能面面俱到,希望讀者也在回覆中給出更多好的建議。

5.1 程式碼優化

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

關於Map:系統程式中使用clientMap來儲存客戶端長連結資訊,Go語言中對於大Map的讀寫要加鎖,有一定的效能限制,在使用者量特別大的情況下,讀者可以對clientMap做拆分,根據使用者id做hash或者採用其他的策略,也可以將這些長連結控制代碼存放到redis中。

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

程式碼還有很多優化的地方,比如:

1)我們可以將鑑權做的更好,使用wss://代替ws://;

2)在一些安全領域,可以對訊息體進行加密,在高併發領域,可以對訊息體進行壓縮;

3)對Mysql連線池再做優化,將訊息持久化儲存到mongo,避免對資料庫頻繁的寫入,將單條寫入改為多條一塊寫入;

4)為了使程式耗費更少的CPU,降低對訊息體進行Json編碼的次數,一次編碼,多次使用......

5.2 系統架構升級

我們的系統太過於簡單,所在在架構升級上,有太多的工作可以做,筆者在這裡只提幾點比較重要的。

1)應用/資源服務分離:

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

2)突破系統連線數,搭建分散式環境:

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

3)業務功能分離:

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

4)減少資料庫I/O,合理利用快取:

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

上邊是就是筆者想到的一些程式碼優化和架構升級的方案。

6、本文結語

不知道大家有沒有發現,使用Go搭建一個IM系統比使用其他語言要簡單很多,而且具備更好的拓展性和效能(並沒有吹噓Go的意思)。

在當今這個時代,5G將要普及,流量不再昂貴,IM系統已經廣泛滲入到了使用者日常生活中。對於程式設計師來說,搭建一個IM系統不再是困難的事情。

如果讀者根據本文的思路,理解Websocket,Copy程式碼,執行程式,應該用不了半天的時間就能上手這樣一個IM系統。

筆者寫本文的目的就是想要幫助更多人了解IM,幫助一些開發者快速的搭建一個應用,燃起大家學習網路程式設計知識的興趣,希望的讀者能有所收穫,能將IM系統應用到更多的產品佈局中。

7、完整原始碼下載

請自行從github下載:

主地址:https://github.com/GuoZhaoran/fastIM

備地址:https://github.com/52im/fastIM

附錄:更多IM開發文章

[1] IM程式碼實踐(適合新手):

《自已開發IM有那麼難嗎?手把手教你自擼一個Andriod版簡易IM (有原始碼)》

《一種Android端IM智慧心跳演算法的設計與實現探討(含樣例程式碼)》

《手把手教你用Netty實現網路通訊程式的心跳機制、斷線重連機制》

《詳解Netty的安全性:原理介紹、程式碼演示(上篇)》

《詳解Netty的安全性:原理介紹、程式碼演示(下篇)》

《Java NIO基礎視訊教程、MINA視訊教程、Netty快速入門視訊 [有原始碼]》

《輕量級即時通訊框架MobileIMSDK的iOS原始碼(開源版)[附件下載]》

《開源IM工程“蘑菇街TeamTalk”2015年5月前未刪減版完整程式碼 [附件下載]》

《NIO框架入門(一):服務端基於Netty4的UDP雙向通訊Demo演示 [附件下載]》

《NIO框架入門(二):服務端基於MINA2的UDP雙向通訊Demo演示 [附件下載]》

《NIO框架入門(三):iOS與MINA2、Netty4的跨平臺UDP雙向通訊實戰 [附件下載]》

《NIO框架入門(四):Android與MINA2、Netty4的跨平臺UDP雙向通訊實戰 [附件下載]》

《高仿Android版手機QQ可拖拽未讀數小氣泡原始碼 [附件下載]》

《一個WebSocket實時聊天室Demo:基於node.js+socket.io [附件下載]》

《Android聊天介面原始碼:實現了聊天氣泡、表情圖示(可翻頁) [附件下載]》

《高仿Android版手機QQ首頁側滑選單原始碼 [附件下載]》

《分享java AMR音訊檔案合併原始碼,全網最全》

《一個基於MQTT通訊協議的完整Android推送Demo [附件下載]》

《高仿手機QQ的Android版鎖屏聊天訊息提醒功能 [附件下載]》

《高仿iOS版手機QQ錄音及振幅動畫完整實現 [原始碼下載]》

《適合新手:從零開發一個IM服務端(基於Netty,有完整原始碼)》

《拿起鍵盤就是幹:跟我一起徒手開發一套分散式IM系統》

《正確理解IM長連線的心跳及重連機制,並動手實現(有完整IM原始碼)》

《適合新手:手把手教你用Go快速搭建高效能、可擴充套件的IM系統(有原始碼)》

>> 更多同類文章 ……

(本文同步釋出自:http://www.52im.net/thread-2988-1-1.html)

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • gRPC 101:在Python中執行Go程式碼