<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    Jack Jiang

    我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
    posts - 494, comments - 13, trackbacks - 0, articles - 1

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

    0、引言

    閱讀提示:本文適合有一定網(wǎng)絡(luò)通信技術(shù)基礎(chǔ)的IM新手閱讀。如果你對網(wǎng)絡(luò)編程,以及IM的一些理論知識知之甚少,請務(wù)必首先閱讀:《新手入門一篇就夠:從零開發(fā)移動端IM》,按需補(bǔ)充相關(guān)知識。

    配套源碼:本文寫的雖然有點淺顯但涉及內(nèi)容不少,建議結(jié)合代碼一起來讀,文章配套的完整源碼 請從本文文末 “11、完整源碼下載” 處下載!

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

    自已開發(fā)IM有那么難嗎?手把手教你自擼一個Andriod版簡易IM (有源碼)

    拿起鍵盤就是干:跟我一起徒手開發(fā)一套分布式IM系統(tǒng)

    適合新手:從零開發(fā)一個IM服務(wù)端(基于Netty,有完整源碼)

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

    本文已同步發(fā)布于“即時通訊技術(shù)圈”公眾號,歡迎關(guān)注:

    ▲ 本文在公眾號上的鏈接是:https://mp.weixin.qq.com/s/ycC-25dkOwAVymHY6WHOEg

    1、正文概述

    前陣子看了《創(chuàng)業(yè)時代》,電視劇的劇情大概是這樣的:IT工程師郭鑫年與好友羅維與投行精英那藍(lán)等人一起,踏上互聯(lián)網(wǎng)創(chuàng)業(yè)之路。創(chuàng)業(yè)開發(fā)的是一款叫做“魔晶”的IM產(chǎn)品。郭鑫年在第一次創(chuàng)業(yè)失敗后,離了婚,還欠了很多外債,騎著自行車經(jīng)歷了西藏一次生死訣別之后產(chǎn)生了靈感,想要創(chuàng)作一款I(lǐng)M產(chǎn)品“魔晶”,“魔晶”的初衷是為了增加人與人之間的感情。雖然劇情純屬虛構(gòu),但確實讓人浮想QQ當(dāng)初的設(shè)想是不是就是這樣的呢?

    有一點是可以確定的,即時通訊確實是一個時代的里程碑。騰訊的強(qiáng)大離不開兩款產(chǎn)品:QQ和微信。這兩款產(chǎn)品設(shè)計的思路是不一樣的,QQ依托于IM系統(tǒng),為了打造個人空間、全民娛樂而設(shè)計,我們常常會看到QQ被初高中生喜愛,QQ賬號也往往與音樂、游戲綁定在一起;微信從QQ導(dǎo)流以后,主打商業(yè)領(lǐng)域,從剛開始推出微信支付與支付寶競爭,在商業(yè)支付領(lǐng)域占得了一席之地(微信支付主要被用戶用于小額支付場景,支付寶主要用在企業(yè)大額轉(zhuǎn)賬、個人金融理財領(lǐng)域)以后。微信又相繼推出了公眾號、小程序,很明顯在商業(yè)領(lǐng)域已經(jīng)占據(jù)了支付寶的上風(fēng),成為了商業(yè)APP中的霸主,后來才有了聊天寶、多閃和馬桶三大門派圍攻微信的鬧劇,結(jié)果大家可能都知道了......

     

    阿里依托于IM系統(tǒng)進(jìn)擊辦公領(lǐng)域,打造了“釘釘”。這又是一款比較精致的產(chǎn)品,其中打卡考勤、請假審批、會議管理都做的非常好,和微信不同的是,企業(yè)通過釘釘交流的信息,對方是能看到信息是否“已讀”的(畢竟是辦公,這個功能還是很有必要的)。騰訊也不甘示弱,創(chuàng)建“企業(yè)微信”,開始和“釘釘”正面交鋒,雖然在市場份額上還是落后于釘釘,但用戶增長很快。

     

    企業(yè)微信于2016年4月發(fā)布1.0版本,也只有簡單的考勤、請假、報銷等功能,在產(chǎn)品功能上略顯平淡。彼時再看釘釘,憑借先發(fā)優(yōu)勢,初期就確定的產(chǎn)品線“討好”老板,2016年企業(yè)數(shù)100萬,2018年這個數(shù)量上升到700萬,可見釘釘發(fā)展速度之快,穩(wěn)固了釘釘在B端市場的地位。企業(yè)微信早期舉棋不定的打法,也讓它在企業(yè)OA辦公上玩不過釘釘。但企業(yè)微信在發(fā)布3.0版本后,局面開始扭轉(zhuǎn),釘釘在用戶數(shù)量上似乎已經(jīng)飽和,難以有新的突破,而企業(yè)微信才真正開始逐漸占據(jù)市場。

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

    筆者今年參加了一次Gopher大會,有幸聽探探的架構(gòu)師分享了它們今年微服務(wù)化的過程,本文快速搭建的IM系統(tǒng)也是使用Go語言來快速實現(xiàn)的,這里先和各位分享一下探探APP的架構(gòu)圖:

     

    以上講了一些IM系統(tǒng)的產(chǎn)品方面不著邊際的廢話,下邊我們回歸主題,大概說一下本文的章節(jié)內(nèi)容安排。

    本文的目的是幫助讀者較為深入的理解socket協(xié)議,并快速搭建一個高可用、可拓展的IM系統(tǒng)。同時幫助讀者了解IM系統(tǒng)后續(xù)可以做哪些優(yōu)化和改進(jìn)。

    本文的內(nèi)容概述:

    • 1)本文演示的IM系統(tǒng)包含基本的注冊、登錄、添加好友基礎(chǔ)功能;
    • 2)提供單聊、群聊,并且支持發(fā)送文字、表情和圖片,在搭建的系統(tǒng)上,讀者可輕松的拓展語音、視頻聊天、發(fā)紅包等業(yè)務(wù)。
    • 2)為了幫助讀者更清楚的理解IM系統(tǒng)的原理,第3節(jié)我會專門深入講解一下websocket協(xié)議,websocket是長鏈接中比較常用的協(xié)議;
    • 3)然后第4節(jié)會講解快速搭建IM系統(tǒng)的技巧和主要代碼實現(xiàn);
    • 4)在第5節(jié)筆者會對IM系統(tǒng)的架構(gòu)升級和優(yōu)化提出一些建議和思路;
    • 5)在最后章節(jié)做本文的回顧總結(jié)。

    2、相關(guān)文章

    更多實踐性代碼參考:

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

    自已開發(fā)IM有那么難嗎?手把手教你自擼一個Andriod版簡易IM (有源碼)

    適合新手:從零開發(fā)一個IM服務(wù)端(基于Netty,有完整源碼)

    拿起鍵盤就是干:跟我一起徒手開發(fā)一套分布式IM系統(tǒng)

    一種Android端IM智能心跳算法的設(shè)計與實現(xiàn)探討(含樣例代碼)

    正確理解IM長連接的心跳及重連機(jī)制,并動手實現(xiàn)(有完整IM源碼)

    手把手教你用Netty實現(xiàn)網(wǎng)絡(luò)通信程序的心跳機(jī)制、斷線重連機(jī)制

    NIO框架入門(一):服務(wù)端基于Netty4的UDP雙向通信Demo演示 [附件下載]

    NIO框架入門(二):服務(wù)端基于MINA2的UDP雙向通信Demo演示 [附件下載]

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

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

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

    相關(guān)IM架構(gòu)方面的文章:

    淺談IM系統(tǒng)的架構(gòu)設(shè)計

    簡述移動端IM開發(fā)的那些坑:架構(gòu)設(shè)計、通信協(xié)議和客戶端

    一套海量在線用戶的移動端IM架構(gòu)設(shè)計實踐分享(含詳細(xì)圖文)

    一套原創(chuàng)分布式即時通訊(IM)系統(tǒng)理論架構(gòu)方案

    從零到卓越:京東客服即時通訊系統(tǒng)的技術(shù)架構(gòu)演進(jìn)歷程

    蘑菇街即時通訊/IM服務(wù)器開發(fā)之架構(gòu)選擇

    一套高可用、易伸縮、高并發(fā)的IM群聊、單聊架構(gòu)方案設(shè)計實踐

    3、深入理解websocket協(xié)議

    3.1 簡介

    WebSocket的目標(biāo)是在一個單獨的持久連接上提供全雙工、雙向通信。在Javascript創(chuàng)建了Web Socket之后,會有一個HTTP請求發(fā)送到瀏覽器以發(fā)起連接。在取得服務(wù)器響應(yīng)后,建立的連接會將HTTP升級從HTTP協(xié)議交換為WebSocket協(xié)議。

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

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

    接下來的篇幅會對Web Sockets的細(xì)節(jié)實現(xiàn)進(jìn)行深入的探索,本文接下來的四個小節(jié)不會涉及到大量的代碼片段,但是會對相關(guān)的API和技術(shù)原理進(jìn)行分析,相信大家讀完下文之后再來看這段描述,會有一種豁然開朗的感覺。

    即時通訊網(wǎng)有大量關(guān)于Web端即時通訊技術(shù)的文章,以下目錄可供你系統(tǒng)地學(xué)習(xí)和了解。

    Web即時通訊新手入門貼:

    新手入門貼:詳解Web端即時通訊技術(shù)的原理

    Web端即時通訊技術(shù)盤點請參見:

    Web端即時通訊技術(shù)盤點:短輪詢、Comet、Websocket、SSE

    有關(guān)Comet技術(shù)的詳細(xì)介紹請參見:

    Comet技術(shù)詳解:基于HTTP長連接的Web端實時通信技術(shù)

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

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

    開源Comet服務(wù)器iComet:支持百萬并發(fā)的Web端即時通訊方案

    更多WebSocket的詳細(xì)介紹請參見:

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

    WebSocket詳解(一):初步認(rèn)識WebSocket技術(shù)

    WebSocket詳解(二):技術(shù)原理、代碼演示和應(yīng)用案例

    WebSocket詳解(三):深入WebSocket通信協(xié)議細(xì)節(jié)

    WebSocket詳解(四):刨根問底HTTP與WebSocket的關(guān)系(上篇)

    WebSocket詳解(五):刨根問底HTTP與WebSocket的關(guān)系(下篇)

    WebSocket詳解(六):刨根問底WebSocket與Socket的關(guān)系

    理論聯(lián)系實際:從零理解WebSocket的通信原理、協(xié)議格式、安全性

    Socket.IO介紹:支持WebSocket、用于WEB端的即時通訊的框架

    socket.io和websocket 之間是什么關(guān)系?有什么區(qū)別?

    有關(guān)SSE的詳細(xì)介紹文章請參見:

    SSE技術(shù)詳解:一種全新的HTML5服務(wù)器推送事件技術(shù)

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

    “握手通道”是HTTP協(xié)議中客戶端和服務(wù)端通過"TCP三次握手"建立的通信通道。客戶端和服務(wù)端使用HTTP協(xié)議進(jìn)行的每次交互都需要先建立這樣一條“通道”,然后通過這條通道進(jìn)行通信。我們熟悉的ajax交互就是在這樣一個通道上完成數(shù)據(jù)傳輸?shù)模徊贿^ajax交互是短連接,在一次 request->response 之后,“通道”連接就斷開了。

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

    在Javascript創(chuàng)建了WebSocket之后,會有一個HTTP請求發(fā)送到瀏覽器以發(fā)起連接,然后服務(wù)端響應(yīng),這就是“握手“的過程。

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

    • 1)建立了一條連接“握手通道”用于通信(這點和HTTP協(xié)議相同,不同的是HTTP協(xié)議完成數(shù)據(jù)交互后就釋放了這條握手通道,這就是所謂的“短連接”,它的生命周期是一次數(shù)據(jù)交互的時間,通常是毫秒級別的);
    • 2)將HTTP協(xié)議升級到WebSocket協(xié)議,并復(fù)用HTTP協(xié)議的握手通道,從而建立一條持久連接。

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

    答案是這樣的:雖然“長連接”在客戶端和服務(wù)端交互的過程中省去了每次都建立“握手通道”的麻煩步驟,但是維持這樣一條“長連接”是需要消耗服務(wù)器資源的,而在大多數(shù)情況下,這種資源的消耗又是不必要的,可以說HTTP標(biāo)準(zhǔn)的制定經(jīng)過了深思熟慮的考量。到我們后邊說到WebSocket協(xié)議數(shù)據(jù)幀時,大家可能就會明白,維持一條“長連接”服務(wù)端和客戶端需要做的事情太多了。

    說完了握手通道,我們再來看HTTP協(xié)議如何升級到WebSocket協(xié)議的。

    3.3 HTTP協(xié)議升級為WebSocket協(xié)議

    升級協(xié)議需要客戶端和服務(wù)端交流,服務(wù)端怎么知道要將HTTP協(xié)議升級到WebSocket協(xié)議呢?它一定是接收到了客戶端發(fā)送過來的某種信號。下面是我從谷歌瀏覽器中截取的“客戶端發(fā)起協(xié)議升級請求的報文”,通過分析這段報文,我們能夠得到有關(guān)WebSocket中協(xié)議升級的更多細(xì)節(jié)。

     

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

    下面是重點請求的首部的意義:

    • 1)Connection:Upgrade:表示要升級的協(xié)議
    • 2)Upgrade: websocket:表示要升級到websocket協(xié)議
    • 3)Sec-WebSocket-Version: 13:表示websocket的版本
    • 4)Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg== :與Response Header中的響應(yīng)首部Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY=是配套的,提供基本的防護(hù),比如惡意的連接或者無意的連接。

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

    那么服務(wù)端怎樣確認(rèn)客戶端發(fā)送過來的請求是否是合法的呢?

    在客戶端每次發(fā)起協(xié)議升級請求的時候都會產(chǎn)生一個唯一碼:Sec-WebSocket-Key。服務(wù)端拿到這個碼后,通過一個算法進(jìn)行校驗,然后通過Sec-WebSocket-Accept響應(yīng)給客戶端,客戶端再對Sec-WebSocket-Accept進(jìn)行校驗來完成驗證。

    這個算法很簡單:

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

    258EAFA5-E914-47DA-95CA-C5AB0DC85B11 這個字符串又叫“魔串",至于為什么要使用它作為Websocket握手計算中使用的字符串,這點我們無需關(guān)心,只需要知道它是RFC標(biāo)準(zhǔn)規(guī)定就可以了,官方的解析也只是簡單的說此值不大可能被不明白WebSocket協(xié)議的網(wǎng)絡(luò)終端使用。

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

    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;

            }

        }

    服務(wù)端響應(yīng)客戶端的頭部信息和HTTP協(xié)議的格式是相同的,HTTP1.1協(xié)議是以換行符(\r\n)分割的,我們可以通過正則匹配解析出Sec-WebSocket-Accept的值,這和我們使用curl工具模擬get請求是一個道理。這樣展示結(jié)果似乎不太直觀,我們使用命令行CLI來根據(jù)上圖中的Sec-WebSocket-Key和握手算法來計算一下服務(wù)端返回的Sec-WebSocket-Accept是否正確。

    如下圖所示: 

    從圖中可以看到,通過算法算出來的base64字符串和Sec-WebSocket-Accept是一樣的。那么假如服務(wù)端在握手的過程中返回一個錯誤的Sec-WebSocket-Accept字符串會怎么樣呢?當(dāng)然是客戶端會報錯,連接會建立失敗,大家可以嘗試一下,例如將全局唯一標(biāo)識符258EAFA5-E914-47DA-95CA-C5AB0DC85B11改為258EAFA5-E914-47DA-95CA-C5AB0DC85B12。

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

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

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

    我們還可以看到瀏覽器控制臺中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實現(xiàn)的,HTTP發(fā)送數(shù)據(jù)也是分包轉(zhuǎn)發(fā)的,就是將大數(shù)據(jù)根據(jù)報文形式分割成一小塊一小塊發(fā)送到服務(wù)端,服務(wù)端接收到客戶端發(fā)送的報文后,再將小塊的數(shù)據(jù)拼接組裝。關(guān)于HTTP的分包策略,大家可以查看相關(guān)資料進(jìn)行研究,websocket協(xié)議也是通過分片打包數(shù)據(jù)進(jìn)行轉(zhuǎn)發(fā)的,不過策略上和HTTP的分包不一樣。

    frame(幀)是websocket發(fā)送數(shù)據(jù)的基本單位,下邊是它的報文格式: 

    報文內(nèi)容中規(guī)定了:數(shù)據(jù)標(biāo)示、操作代碼、掩碼、數(shù)據(jù)、數(shù)據(jù)長度等格式。

    不太理解沒關(guān)系,下面我通過講解大家只要理解報文中重要標(biāo)志的作用就可以了。

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

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

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

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

    websocket通信中,客戶端發(fā)送數(shù)據(jù)分片是有序的,這一點和HTTP不一樣,HTTP將消息分包之后,是并發(fā)無序的發(fā)送給服務(wù)端的,包信息在數(shù)據(jù)中的位置則在HTTP報文中存儲,而websocket僅僅需要一個FIN比特位就能保證將數(shù)據(jù)完整的發(fā)送到服務(wù)端。

    接下來的RSV1,RSV2,RSV3三個比特位的作用又是什么呢?這三個標(biāo)志位是留給客戶端開發(fā)者和服務(wù)端開發(fā)者開發(fā)過程中協(xié)商進(jìn)行拓展的,默認(rèn)是0。拓展如何使用必須在握手的階段就協(xié)商好,其實握手本身也是客戶端和服務(wù)端的協(xié)商。

    3.5 Websocket連接保持和心跳檢測

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

    但是對于長時間沒有數(shù)據(jù)往來的連接,如果依舊保持著,可能會浪費服務(wù)端資源。但是不排除有些場景,客戶端和服務(wù)端雖然長時間沒有數(shù)據(jù)往來,仍然需要保持連接。就比如說你幾個月沒有和一個QQ好友聊天了,突然有一天他發(fā)QQ消息告訴你他要結(jié)婚了,你還是能在第一時間收到。那是因為,客戶端和服務(wù)端一直再采用心跳來檢查連接。

    客戶端和服務(wù)端的心跳連接檢測就像打乒乓球一樣:

    • 1)發(fā)送方->接收方:ping
    • 2)接收方->發(fā)送方:pong

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

    說了這么多,接下來我使用Go語言來實現(xiàn)一個心跳檢測。

    Websocket通信實現(xiàn)細(xì)節(jié)是一件繁瑣的事情,直接使用開源的類庫是比較不錯的選擇,我使用的是:gorilla/websocket。這個類庫已經(jīng)將websocket的實現(xiàn)細(xì)節(jié)(握手,數(shù)據(jù)解碼)封裝的很好啦。

    下面我就直接貼代碼了:

    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

       )

       //服務(wù)端對客戶端的http請求(升級為websocket協(xié)議)進(jìn)行應(yīng)答,應(yīng)答之后,協(xié)議升級為websocket,http建立連接時的tcp三次握手將保持。

       if conn, err = upgrade.Upgrade(w, r, nil); err != nil{

            return

       }

        //啟動一個協(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的長鏈接之后,就可以對客戶端傳遞的數(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{

             gotoERR

         }

       }

    ERR:

        //出錯之后,關(guān)閉socket連接

        conn.Close()

    }

    func main() {

        http.HandleFunc("/ws", wsHandler)

        http.ListenAndServe("0.0.0.0:7777", nil)

    }

    借助go語言很容易搭建協(xié)程的特點,我專門開啟了一個協(xié)程每秒向客戶端發(fā)送一條消息。打開客戶端瀏覽器可以看到,frame中每秒的心跳數(shù)據(jù)一直在跳動,當(dāng)長鏈接斷開之后,心跳就沒有了。

    就像人沒有了心跳一樣: 

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

    4、開始動手,快速搭建高性能、可拓展的IM系統(tǒng)

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

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

    簡要介紹一下本次IM的技術(shù)實現(xiàn)情況:

    • 1)我們本節(jié)C端使用的是Webapp, 通過Go語言渲染Vue模版快速實現(xiàn)功能;
    • 2)接入層使用的是websocket協(xié)議,前邊已經(jīng)進(jìn)行了深入的介紹;
    • 3)S端是我們實現(xiàn)的重點,其中鑒權(quán)、登錄、關(guān)系管理、單聊和群聊的功能都已經(jīng)實現(xiàn),讀者可以在這部分功能的基礎(chǔ)上再拓展其他的功能,比如:視頻語音聊天、發(fā)紅包、朋友圈等業(yè)務(wù)模塊;
    • 4)存儲層我們做的比較簡單,只是使用Mysql簡單持久化存儲了用戶關(guān)系,然后聊天中的圖片資源我們存儲到了本地文件中。

    雖然我們的IM系統(tǒng)實現(xiàn)的比較簡化,但是讀者可以在次基礎(chǔ)上進(jìn)行改進(jìn)、完善、拓展,依然能夠作出高可用的企業(yè)級產(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ù)定義與存儲

    │   │   ├── community.go

    │   │   ├── contract.go

    │   │   ├── init.go

    │   │   └── user.go

    │   ├── service             //邏輯實現(xiàn)

    │   │   ├── contract.go

    │   │   └── user.go

    │   ├── util                //幫助函數(shù)   

    │   │   ├── md5.go

    │   │   ├── parse.go

    │   │   ├── resp.go

    │   │   └── string.go

    │   └── view                //模版資源

    │   │   ├── ...

    asset                       //js、css文件

    resource                    //上傳資源,上傳圖片會放到這里

    源碼的具體說明如下:

    • 1)從入口函數(shù)main.go開始,我們定義了controller層,是客戶端api的入口;
    • 2)service用來處理主要的用戶邏輯,消息分發(fā)、用戶管理都在這里實現(xiàn);
    • 3)model層定義了一些數(shù)據(jù)表,主要是用戶注冊和用戶好友關(guān)系、群組等信息,存儲到mysql;
    • 4)util包下是一些幫助函數(shù),比如加密、請求響應(yīng)等;
    • 5)view下邊存儲了模版資源信息,上邊所說的這些都在app文件夾下存儲;
    • 6)外層還有asset用來存儲css、js文件和聊天中會用到的表情圖片等;
    • 7)resource下存儲用戶聊天中的圖片或者視頻等文件。

    總體來講,我們的代碼目錄機(jī)構(gòu)還是比較簡潔清晰的。

    了解了我們要搭建的IM系統(tǒng)架構(gòu),我們再來看一下架構(gòu)重點實現(xiàn)的功能吧。

    4.2 10行代碼萬能模版渲染

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

    代碼如下:

    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實現(xiàn)靜態(tài)資源服務(wù)器也很簡單,只需要調(diào)用http.FileServer就可以了,這樣html文件就可以很輕松的訪問依賴的js、css和圖標(biāo)文件了。使用http/template包下的ParseGlob、ExecuteTemplate又可以很輕松的解析web頁面,這些工作完全不依賴與nginx。

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

     

     

    4.3 注冊、登錄和鑒權(quán)

    之前我們提到過,對于注冊、登錄和好友關(guān)系管理,我們需要有一張user表來存儲用戶信息。我們使用https://github.com/go-xorm/xorm來操作mysql。

    首先看一下mysql表的設(shè)計。

    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)建時間, 統(tǒng)計用戶增量時使用

    }

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

    接下來我們看一下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)

            //設(shè)置數(shù)據(jù)庫連接數(shù)

            DbEngine.SetMaxOpenConns(10)

            //自動創(chuàng)建數(shù)據(jù)庫

            DbEngine.Sync(new(User), new(Community), new(Contact))

     

            fmt.Println("init database ok!")

    }

    我們創(chuàng)建一個DbEngine全局mysql連接對象,設(shè)置了一個大小為10的連接池。model包里的init函數(shù)在程序加載的時候會先執(zhí)行,對Go語言熟悉的同學(xué)應(yīng)該知道這一點。我們還設(shè)置了一些額外的參數(shù)用于調(diào)試程序,比如:設(shè)置打印運行中的sql,自動的同步數(shù)據(jù)表等,這些功能在生產(chǎn)環(huán)境中可以關(guān)閉。我們的model初始化工作就做完了,非常簡陋,在實際的項目中,像數(shù)據(jù)庫的用戶名、密碼、連接數(shù)和其他的配置信息,建議設(shè)置到配置文件中,然后讀取,而不像本文硬編碼的程序中。

    注冊是一個普通的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

            }

            //如果用戶已經(jīng)注冊,返回錯誤信息

            if registerUser.Id > 0 {

                    return registerUser, errors.New("該手機(jī)號已注冊")

            }

     

            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對象上,使用的是util包中的Bind函數(shù),具體實現(xiàn)細(xì)節(jié)讀者可以自行研究,主要模仿了Gin框架的參數(shù)綁定,可以拿來即用,非常方便。然后我們根據(jù)用戶手機(jī)號搜索數(shù)據(jù)庫中是否已經(jīng)存在,如果不存在就插入到數(shù)據(jù)庫中,返回注冊成功信息,邏輯非常簡單。

    登錄邏輯更簡單:

    ############################

    //app/controller/user.go

    ############################

    ...

    //用戶登錄

    func UserLogin(writer http.ResponseWriter, request *http.Request) {

            request.ParseForm()

     

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

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

     

            //校驗參數(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)

    }

    實現(xiàn)了登錄邏輯,接下來我們就到了用戶首頁,這里列出了用戶列表,點擊即可進(jìn)入聊天頁面。用戶也可以點擊下邊的tab欄查看自己所在的群組,可以由此進(jìn)入群組聊天頁面。

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

    我們再重點看一下用戶鑒權(quán)這一塊吧,用戶鑒權(quán)是指用戶點擊聊天進(jìn)入聊天界面時,客戶端會發(fā)送一個GET請求給服務(wù)端,請求建立一條websocket長連接,服務(wù)端收到建立連接的請求之后,會對客戶端請求進(jìn)行校驗,以確實是否建立長連接,然后將這條長連接的句柄添加到map當(dāng)中(因為服務(wù)端不僅僅對一個客戶端服務(wù),可能存在千千萬萬個長連接)維護(hù)起來。

    我們下邊來看具體代碼實現(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

    //實現(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)

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

     

            //開啟協(xié)程處理發(fā)送邏輯

            go sendproc(node)

     

            //開啟協(xié)程完成接收邏輯

            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)

    }

    ......

    進(jìn)入聊天室,客戶端發(fā)起/chat的GET請求,服務(wù)端首先創(chuàng)建了一個Node結(jié)構(gòu)體,用來存儲和客戶端建立起來的websocket長連接句柄,每一個句柄都有一個管道DataQueue,用來收發(fā)信息,GroupSets是客戶端對應(yīng)的群組信息,后邊我們會提到。

    type Node struct{

            Conn *websocket.Conn

            //并行轉(zhuǎn)串行,

            DataQueue chan[]byte

            GroupSets set.Interface

    }

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

    //userid和Node映射關(guān)系表

    var clientMap map[int64]*Node = make(map[int64]*Node, 0)

    接下來是主要的用戶邏輯了,服務(wù)端接收到客戶端的參數(shù)之后,首先校驗token是否合法,由此確定是否要升級http協(xié)議到websocket協(xié)議,建立長連接,這一步稱為鑒權(quá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ù)端初始化一個Node,搜索該客戶端用戶所在的群組id,填充到群組的GroupSets屬性中。然后將Node節(jié)點添加到ClientMap中維護(hù)起來,我們對ClientMap的操作一定要加鎖,因為Go語言在并發(fā)情況下,對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()

    服務(wù)端和客戶端建立了長鏈接之后,會開啟兩個協(xié)程專門來處理客戶端消息的收發(fā)工作,對于Go語言來說,維護(hù)協(xié)程的代價是很低的,所以說我們的單機(jī)程序可以很輕松的支持成千上完的用戶聊天,這還是在沒有優(yōu)化的情況下。

    代碼如下:

    ......

    //開啟協(xié)程處理發(fā)送邏輯

            go sendproc(node)

     

            //開啟協(xié)程完成接收邏輯

            go recvproc(node)

     

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

    ......

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

    4.4 實現(xiàn)單聊和群聊

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

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

    ############################

    //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"`//對端用戶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)的

    }

    每一條消息都有一個唯一的id,將來我們可以對消息持久化存儲,但是我們系統(tǒng)中并沒有做這件工作,讀者可根據(jù)需要自行完成。然后是userid,發(fā)起消息的用戶,對應(yīng)的是dstid,要將消息發(fā)送給誰。

    還有一個參數(shù)非常重要,就是cmd,它表示是群聊還是私聊,群聊和私聊的代碼處理邏輯有所區(qū)別。

    我們?yōu)榇藢iT定義了一些cmd常量:

    //定義命令行格式

    const(

            CmdSingleMsg = 10

            CmdRoomMsg   = 11

            CmdHeart     = 0

    )

    • media是媒體類型,我們都知道微信支持語音、視頻和各種其他的文件傳輸,我們設(shè)置了該參數(shù)之后,讀者也可以自行拓展這些功能;
    • content是消息文本,是聊天中最常用的一種形式;
    • pic和url是為圖片和其他鏈接資源所設(shè)置的;
    • memo是簡介;
    • amount是和數(shù)字相關(guān)的信息,比如說發(fā)紅包業(yè)務(wù)有可能使用到該字段。

    消息體的設(shè)計就是這樣,基于此消息體,我們來看一下,服務(wù)端如何收發(fā)消息,實現(xiàn)單聊和群聊吧。還是從上一節(jié)說起,我們?yōu)槊恳粋€客戶端長鏈接開啟了兩個協(xié)程,用于收發(fā)消息,聊天的邏輯就在這兩個協(xié)程當(dāng)中實現(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對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 := rangeclientMap {

                            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就可以實現(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對data進(jìn)一步處理

                    fmt.Printf("recv<=%s", data)

            }

    }

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

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

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

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

    其實更好的實踐是我們再維護(hù)一個群組和用戶關(guān)系的Map,這樣在發(fā)送群組消息的時候,取得用戶信息就比遍歷整個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,我們實現(xiàn)用戶聊天功能還是非常方便的,代碼可讀性很強(qiáng),構(gòu)建的程序也很健壯。

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

     

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

    下邊我們再來看一下聊天中經(jīng)常使用到的發(fā)送表情和圖片功能是如何實現(xiàn)的吧。

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

    下邊是一個聊天中返回的圖標(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)

    }

     

    //將文件存儲在本地/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)建一個新的文件

            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)

    }

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

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

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

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

    本節(jié)主要從代碼優(yōu)化和架構(gòu)升級上談一些個人觀點,能力有限不可能面面俱到,希望讀者也在回復(fù)中給出更多好的建議。

    5.1 代碼優(yōu)化

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

    關(guān)于Map:系統(tǒng)程序中使用clientMap來存儲客戶端長鏈接信息,Go語言中對于大Map的讀寫要加鎖,有一定的性能限制,在用戶量特別大的情況下,讀者可以對clientMap做拆分,根據(jù)用戶id做hash或者采用其他的策略,也可以將這些長鏈接句柄存放到redis中。

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

    代碼還有很多優(yōu)化的地方,比如:

    • 1)我們可以將鑒權(quán)做的更好,使用wss://代替ws://;
    • 2)在一些安全領(lǐng)域,可以對消息體進(jìn)行加密,在高并發(fā)領(lǐng)域,可以對消息體進(jìn)行壓縮;
    • 3)對Mysql連接池再做優(yōu)化,將消息持久化存儲到mongo,避免對數(shù)據(jù)庫頻繁的寫入,將單條寫入改為多條一塊寫入;
    • 4)為了使程序耗費更少的CPU,降低對消息體進(jìn)行Json編碼的次數(shù),一次編碼,多次使用......

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

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

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

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

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

    對于服務(wù)器的選擇,一般會選擇linux,linux下一切皆文件,長鏈接也是一樣。單機(jī)的系統(tǒng)連接數(shù)是有限制的,一般來說能達(dá)到10萬就很不錯了,所以在用戶量增長到一定程序,需要搭建分布式。分布式的搭建就要優(yōu)化程序,因為長鏈接句柄分散到不同的機(jī)器,實現(xiàn)消息廣播和分發(fā)是首先要解決的問題,筆者這里不深入闡述了,一來是沒有足夠的經(jīng)驗,二來是解決方案有太多的細(xì)節(jié)需要探討。搭建分布式環(huán)境所面臨的問題還有:怎樣更好的彈性擴(kuò)容、應(yīng)對突發(fā)事件等。

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

    我們上邊將用戶注冊、添加好友等功能和聊天功能放到了一起,真實的業(yè)務(wù)場景中可以將它們做分離,將用戶注冊、添加好友、創(chuàng)建群組放到一臺服務(wù)器上,將聊天功能放到另外的服務(wù)器上。業(yè)務(wù)的分離不僅使功能邏輯更加清晰,還能更有效的利用服務(wù)器資源。

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

    我們的系統(tǒng)沒有將消息持久化,用戶信息持久化到mysql中去。在業(yè)務(wù)當(dāng)中,如果要對消息做持久化儲存,就要考慮數(shù)據(jù)庫I/O的優(yōu)化,簡單講:合并數(shù)據(jù)庫的寫次數(shù)、優(yōu)化數(shù)據(jù)庫的讀操作、合理的利用緩存。

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

    6、本文結(jié)語

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

    在當(dāng)今這個時代,5G將要普及,流量不再昂貴,IM系統(tǒng)已經(jīng)廣泛滲入到了用戶日常生活中。對于程序員來說,搭建一個IM系統(tǒng)不再是困難的事情。

    如果讀者根據(jù)本文的思路,理解Websocket,Copy代碼,運行程序,應(yīng)該用不了半天的時間就能上手這樣一個IM系統(tǒng)。

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

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

    7、完整源碼下載

    請自行從github下載:

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

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

    附錄:更多IM開發(fā)文章

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

    自已開發(fā)IM有那么難嗎?手把手教你自擼一個Andriod版簡易IM (有源碼)

    一種Android端IM智能心跳算法的設(shè)計與實現(xiàn)探討(含樣例代碼)

    手把手教你用Netty實現(xiàn)網(wǎng)絡(luò)通信程序的心跳機(jī)制、斷線重連機(jī)制

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

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

    微信本地數(shù)據(jù)庫破解版(含iOS、Android),僅供學(xué)習(xí)研究 [附件下載]

    Java NIO基礎(chǔ)視頻教程、MINA視頻教程、Netty快速入門視頻 [有源碼]

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

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

    微信本地數(shù)據(jù)庫破解版(含iOS、Android),僅供學(xué)習(xí)研究 [附件下載]

    NIO框架入門(一):服務(wù)端基于Netty4的UDP雙向通信Demo演示 [附件下載]

    NIO框架入門(二):服務(wù)端基于MINA2的UDP雙向通信Demo演示 [附件下載]

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

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

    用于IM中圖片壓縮的Android工具類源碼,效果可媲美微信 [附件下載]

    高仿Android版手機(jī)QQ可拖拽未讀數(shù)小氣泡源碼 [附件下載]

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

    Android聊天界面源碼:實現(xiàn)了聊天氣泡、表情圖標(biāo)(可翻頁) [附件下載]

    高仿Android版手機(jī)QQ首頁側(cè)滑菜單源碼 [附件下載]

    開源libco庫:單機(jī)千萬連接、支撐微信8億用戶的后臺框架基石 [源碼下載]

    分享java AMR音頻文件合并源碼,全網(wǎng)最全

    微信團(tuán)隊原創(chuàng)Android資源混淆工具:AndResGuard [有源碼]

    一個基于MQTT通信協(xié)議的完整Android推送Demo [附件下載]

    Android版高仿微信聊天界面源碼 [附件下載]

    高仿手機(jī)QQ的Android版鎖屏聊天消息提醒功能 [附件下載]

    高仿iOS版手機(jī)QQ錄音及振幅動畫完整實現(xiàn) [源碼下載]

    Android端社交應(yīng)用中的評論和回復(fù)功能實戰(zhàn)分享[圖文+源碼]

    Android端IM應(yīng)用中的@人功能實現(xiàn):仿微博、QQ、微信,零入侵、高可擴(kuò)展[圖文+源碼]

    仿微信的IM聊天時間顯示格式(含iOS/Android/Web實現(xiàn))[圖文+源碼]

    Android版仿微信朋友圈圖片拖拽返回效果 [源碼下載]

    適合新手:從零開發(fā)一個IM服務(wù)端(基于Netty,有完整源碼)

    拿起鍵盤就是干:跟我一起徒手開發(fā)一套分布式IM系統(tǒng)

    正確理解IM長連接的心跳及重連機(jī)制,并動手實現(xiàn)(有完整IM源碼)

    適合新手:手把手教你用Go快速搭建高性能、可擴(kuò)展的IM系統(tǒng)(有源碼)

    >> 更多同類文章 …… 

    歡迎關(guān)注我的“即時通訊技術(shù)圈”公眾號:

    (本文同步發(fā)布自:http://www.52im.net/thread-2988-1-1.html



    作者:Jack Jiang (點擊作者姓名進(jìn)入Github)
    出處:http://www.52im.net/space-uid-1.html
    交流:歡迎加入即時通訊開發(fā)交流群 215891622
    討論:http://www.52im.net/
    Jack Jiang同時是【原創(chuàng)Java Swing外觀工程BeautyEye】【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
    本博文 歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處(也可前往 我的52im.net 找到我)。


    只有注冊用戶登錄后才能發(fā)表評論。


    網(wǎng)站導(dǎo)航:
     
    Jack Jiang的 Mail: jb2011@163.com, 聯(lián)系QQ: 413980957, 微信: hellojackjiang
    主站蜘蛛池模板: 成人亚洲综合天堂| 亚洲av无码精品网站| 亚洲国产a∨无码中文777| 亚洲国产精品综合久久20| 国产精品成人免费观看| 成年人免费的视频| 国产亚洲成人久久| 亚洲综合色丁香婷婷六月图片 | 国产国产人免费视频成69大陆| 曰韩亚洲av人人夜夜澡人人爽| 亚洲无mate20pro麻豆| 两个人www免费高清视频| 成年女人免费v片| 亚洲AV无码久久| 日韩一级片免费观看| 免费看黄视频网站| 国产成人精品日本亚洲| 最新亚洲人成网站在线观看| 99久在线国内在线播放免费观看 | 暖暖在线日本免费中文| 亚洲精品免费观看| 水蜜桃视频在线观看免费| 两性刺激生活片免费视频| 国产AV无码专区亚洲AV毛网站| 亚洲AV成人精品日韩一区| 国产人成免费视频网站| 人人狠狠综合久久亚洲婷婷| 免费亚洲视频在线观看| 三年片在线观看免费观看高清电影 | 日本一道高清不卡免费| 亚洲精品国产第1页| 国产午夜不卡AV免费| 亚洲成a人片在线观看老师| 亚洲中文无码永久免| 青青草无码免费一二三区| 国产亚洲精久久久久久无码AV| 国产精品亚洲专区一区| 国产免费av片在线看| 亚洲成人网在线播放| 久章草在线精品视频免费观看| 亚洲一区视频在线播放 |