本文為開源工程:“github.com/GuoZhaoran/fastIM”的配套文章,原作者:“繪你一世傾城”,現為:獵豹移動php開發工程師,感謝原作者的技術分享。
0、引言
閱讀提示:本文適合有一定網絡通信技術基礎的IM新手閱讀。如果你對網絡編程,以及IM的一些理論知識知之甚少,請務必首先閱讀:《新手入門一篇就夠:從零開發移動端IM》,按需補充相關知識。
配套源碼:本文寫的雖然有點淺顯但涉及內容不少,建議結合代碼一起來讀,文章配套的完整源碼 請從本文文末 “11、完整源碼下載” 處下載!
本站的另幾篇同類代碼你可能也喜歡:
另外:本文作者的另一篇文章,有興趣也可以關注一下:《12306搶票帶來的啟示:看我如何用Go實現百萬QPS的秒殺系統(含源碼)》。
本文已同步發布于“即時通訊技術圈”公眾號,歡迎關注:

▲ 本文在公眾號上的鏈接是:https://mp.weixin.qq.com/s/ycC-25dkOwAVymHY6WHOEg
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,有完整源碼)》
《一種Android端IM智能心跳算法的設計與實現探討(含樣例代碼)》
《正確理解IM長連接的心跳及重連機制,并動手實現(有完整IM源碼)》
《手把手教你用Netty實現網絡通信程序的心跳機制、斷線重連機制》
《NIO框架入門(一):服務端基于Netty4的UDP雙向通信Demo演示 [附件下載]》
《NIO框架入門(二):服務端基于MINA2的UDP雙向通信Demo演示 [附件下載]》
《NIO框架入門(三):iOS與MINA2、Netty4的跨平臺UDP雙向通信實戰 [附件下載]》
相關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端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》
有關Comet技術的詳細介紹請參見:
《Comet技術詳解:基于HTTP長連接的Web端實時通信技術》
《WEB端即時通訊:HTTP長連接、長輪詢(long polling)詳解》
《WEB端即時通訊:不用WebSocket也一樣能搞定消息的即時性》
《開源Comet服務器iComet:支持百萬并發的Web端即時通訊方案》
更多WebSocket的詳細介紹請參見:
《WebSocket詳解(一):初步認識WebSocket技術》
《WebSocket詳解(二):技術原理、代碼演示和應用案例》
《WebSocket詳解(三):深入WebSocket通信協議細節》
《WebSocket詳解(四):刨根問底HTTP與WebSocket的關系(上篇)》
《WebSocket詳解(五):刨根問底HTTP與WebSocket的關系(下篇)》
《WebSocket詳解(六):刨根問底WebSocket與Socket的關系》
《理論聯系實際:從零理解WebSocket的通信原理、協議格式、安全性》
《Socket.IO介紹:支持WebSocket、用于WEB端的即時通訊的框架》
《socket.io和websocket 之間是什么關系?有什么區別?》
有關SSE的詳細介紹文章請參見:
3.2 WebSocket復用了HTTP的握手通道
“握手通道”是HTTP協議中客戶端和服務端通過"TCP三次握手"建立的通信通道。客戶端和服務端使用HTTP協議進行的每次交互都需要先建立這樣一條“通道”,然后通過這條通道進行通信。我們熟悉的ajax交互就是在這樣一個通道上完成數據傳輸的,只不過ajax交互是短連接,在一次 request->response 之后,“通道”連接就斷開了。
下面是HTTP協議中建立“握手通道”的過程示意圖:

在Javascript創建了WebSocket之后,會有一個HTTP請求發送到瀏覽器以發起連接,然后服務端響應,這就是“握手“的過程。
在這個握手的過程當中,客戶端和服務端主要做了兩件事情:
- 1)建立了一條連接“握手通道”用于通信(這點和HTTP協議相同,不同的是HTTP協議完成數據交互后就釋放了這條握手通道,這就是所謂的“短連接”,它的生命周期是一次數據交互的時間,通常是毫秒級別的);
- 2)將HTTP協議升級到WebSocket協議,并復用HTTP協議的握手通道,從而建立一條持久連接。
說到這里可能有人會問:HTTP協議為什么不復用自己的“握手通道”,而非要在每次進行數據交互的時候都通過TCP三次握手重新建立“握手通道”呢?
答案是這樣的:雖然“長連接”在客戶端和服務端交互的過程中省去了每次都建立“握手通道”的麻煩步驟,但是維持這樣一條“長連接”是需要消耗服務器資源的,而在大多數情況下,這種資源的消耗又是不必要的,可以說HTTP標準的制定經過了深思熟慮的考量。到我們后邊說到WebSocket協議數據幀時,大家可能就會明白,維持一條“長連接”服務端和客戶端需要做的事情太多了。
說完了握手通道,我們再來看HTTP協議如何升級到WebSocket協議的。
3.3 HTTP協議升級為WebSocket協議
升級協議需要客戶端和服務端交流,服務端怎么知道要將HTTP協議升級到WebSocket協議呢?它一定是接收到了客戶端發送過來的某種信號。下面是我從谷歌瀏覽器中截取的“客戶端發起協議升級請求的報文”,通過分析這段報文,我們能夠得到有關WebSocket中協議升級的更多細節。

首先,客戶端發起協議升級請求。采用的是標準的HTTP報文格式,且只支持GET方法。
下面是重點請求的首部的意義:
- 1)Connection:Upgrade:表示要升級的協議
- 2)Upgrade: websocket:表示要升級到websocket協議
- 3)Sec-WebSocket-Version: 13:表示websocket的版本
- 4)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)標識: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是否正確。
如下圖所示:

從圖中可以看到,通過算法算出來的base64字符串和Sec-WebSocket-Accept是一樣的。那么假如服務端在握手的過程中返回一個錯誤的Sec-WebSocket-Accept字符串會怎么樣呢?當然是客戶端會報錯,連接會建立失敗,大家可以嘗試一下,例如將全局唯一標識符258EAFA5-E914-47DA-95CA-C5AB0DC85B11改為258EAFA5-E914-47DA-95CA-C5AB0DC85B12。
3.4 WebSocket的幀和數據分片傳輸
下圖是我做的一個測試:將小說《飄》的第一章內容復制成文本數據,通過客戶端發送到服務端,然后服務端響應相同的信息完成了一次通信。

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

報文內容中規定了:數據標示、操作代碼、掩碼、數據、數據長度等格式。
不太理解沒關系,下面我通過講解大家只要理解報文中重要標志的作用就可以了。
首先我們明白了客戶端和服務端進行Websocket消息傳遞是這樣的:
- 1)客戶端:將消息切割成多個幀,并發送給服務端;
- 2)服務端:接收消息幀,并將關聯的幀重新組裝成完整的消息。
服務端在接收到客戶端發送的幀消息的時候,將這些幀進行組裝,它怎么知道何時數據組裝完成的呢?
這就是報文中左上角FIN(占一個比特)存儲的信息,1表示這是消息的最后一個分片(fragment)如果是0,表示不是消息的最后一個分片。
websocket通信中,客戶端發送數據分片是有序的,這一點和HTTP不一樣,HTTP將消息分包之后,是并發無序的發送給服務端的,包信息在數據中的位置則在HTTP報文中存儲,而websocket僅僅需要一個FIN比特位就能保證將數據完整的發送到服務端。
接下來的RSV1,RSV2,RSV3三個比特位的作用又是什么呢?這三個標志位是留給客戶端開發者和服務端開發者開發過程中協商進行拓展的,默認是0。拓展如何使用必須在握手的階段就協商好,其實握手本身也是客戶端和服務端的協商。
3.5 Websocket連接保持和心跳檢測
Websocket是長連接,為了保持客戶端和服務端的實時雙向通信,需要確保客戶端和服務端之間的TCP通道保持連接沒有斷開。
但是對于長時間沒有數據往來的連接,如果依舊保持著,可能會浪費服務端資源。但是不排除有些場景,客戶端和服務端雖然長時間沒有數據往來,仍然需要保持連接。就比如說你幾個月沒有和一個QQ好友聊天了,突然有一天他發QQ消息告訴你他要結婚了,你還是能在第一時間收到。那是因為,客戶端和服務端一直再采用心跳來檢查連接。
客戶端和服務端的心跳連接檢測就像打乒乓球一樣:
- 1)發送方->接收方:ping
- 2)接收方->發送方: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{
returntrue
},
}
)
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{
gotoERR
}
}
ERR:
//出錯之后,關閉socket連接
conn.Close()
}
func main() {
http.HandleFunc("/ws", wsHandler)
http.ListenAndServe("0.0.0.0:7777", nil)
}
借助go語言很容易搭建協程的特點,我專門開啟了一個協程每秒向客戶端發送一條消息。打開客戶端瀏覽器可以看到,frame中每秒的心跳數據一直在跳動,當長鏈接斷開之后,心跳就沒有了。
就像人沒有了心跳一樣:

大家對websocket協議已經有了了解,接下來就讓我們一起快速搭建一個高性能、可拓展的IM系統吧。
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是客戶端對應的群組信息,后邊我們會提到。
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 := 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系統是一個時代,從QQ、微信到現在的人工智能,都廣泛應用了即時通信,圍繞即時通信,又可以做更多產品布局。
筆者寫本文的目的就是想要幫助更多人了解IM,幫助一些開發者快速的搭建一個應用,燃起大家學習網絡編程知識的興趣,希望的讀者能有所收獲,能將IM系統應用到更多的產品布局中。
7、完整源碼下載
請自行從github下載:
附錄:更多IM開發文章
[1] IM代碼實踐(適合新手):
《自已開發IM有那么難嗎?手把手教你自擼一個Andriod版簡易IM (有源碼)》
《一種Android端IM智能心跳算法的設計與實現探討(含樣例代碼)》
《手把手教你用Netty實現網絡通信程序的心跳機制、斷線重連機制》
《微信本地數據庫破解版(含iOS、Android),僅供學習研究 [附件下載]》
《Java NIO基礎視頻教程、MINA視頻教程、Netty快速入門視頻 [有源碼]》
《輕量級即時通訊框架MobileIMSDK的iOS源碼(開源版)[附件下載]》
《開源IM工程“蘑菇街TeamTalk”2015年5月前未刪減版完整代碼 [附件下載]》
《微信本地數據庫破解版(含iOS、Android),僅供學習研究 [附件下載]》
《NIO框架入門(一):服務端基于Netty4的UDP雙向通信Demo演示 [附件下載]》
《NIO框架入門(二):服務端基于MINA2的UDP雙向通信Demo演示 [附件下載]》
《NIO框架入門(三):iOS與MINA2、Netty4的跨平臺UDP雙向通信實戰 [附件下載]》
《NIO框架入門(四):Android與MINA2、Netty4的跨平臺UDP雙向通信實戰 [附件下載]》
《用于IM中圖片壓縮的Android工具類源碼,效果可媲美微信 [附件下載]》
《高仿Android版手機QQ可拖拽未讀數小氣泡源碼 [附件下載]》
《一個WebSocket實時聊天室Demo:基于node.js+socket.io [附件下載]》
《Android聊天界面源碼:實現了聊天氣泡、表情圖標(可翻頁) [附件下載]》
《高仿Android版手機QQ首頁側滑菜單源碼 [附件下載]》
《開源libco庫:單機千萬連接、支撐微信8億用戶的后臺框架基石 [源碼下載]》
《微信團隊原創Android資源混淆工具:AndResGuard [有源碼]》
《一個基于MQTT通信協議的完整Android推送Demo [附件下載]》
《高仿手機QQ的Android版鎖屏聊天消息提醒功能 [附件下載]》
《高仿iOS版手機QQ錄音及振幅動畫完整實現 [源碼下載]》
《Android端社交應用中的評論和回復功能實戰分享[圖文+源碼]》
《Android端IM應用中的@人功能實現:仿微博、QQ、微信,零入侵、高可擴展[圖文+源碼]》
《仿微信的IM聊天時間顯示格式(含iOS/Android/Web實現)[圖文+源碼]》
《Android版仿微信朋友圈圖片拖拽返回效果 [源碼下載]》
《適合新手:從零開發一個IM服務端(基于Netty,有完整源碼)》
《正確理解IM長連接的心跳及重連機制,并動手實現(有完整IM源碼)》
《適合新手:手把手教你用Go快速搭建高性能、可擴展的IM系統(有源碼)》
>> 更多同類文章 ……
歡迎關注我的“即時通訊技術圈”公眾號:
(本文同步發布自:http://www.52im.net/thread-2988-1-1.html)
作者:Jack Jiang (點擊作者姓名進入Github)
出處:http://www.52im.net/space-uid-1.html
交流:歡迎加入即時通訊開發交流群 215891622
討論:http://www.52im.net/
Jack Jiang同時是【原創Java
Swing外觀工程BeautyEye】和【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
本博文
歡迎轉載,轉載請注明出處(也可前往 我的52im.net 找到我)。