歡迎轉(zhuǎn)載,轉(zhuǎn)載請(qǐng)注明出處:http://www.reibang.com/p/00ba0ac2fc96
寫(xiě)在前面
一直想寫(xiě)一篇關(guān)于im即時(shí)通訊分享的文章浩考,無(wú)奈工作太忙,很難抽出時(shí)間被盈。今天終于從公司離職了,打算好好休息幾天再重新找工作搭伤,趁時(shí)間空閑只怎,決定靜下心來(lái)寫(xiě)一篇文章,畢竟從前輩那里學(xué)到了很多東西怜俐。工作了五年半身堡,這三四年來(lái)一直在做社交相關(guān)的項(xiàng)目,有
直播拍鲤、
即時(shí)通訊贴谎、
短視頻分享、
社區(qū)論壇
等產(chǎn)品季稳,深知即時(shí)通訊技術(shù)在一個(gè)項(xiàng)目中的重要性擅这,本著開(kāi)源分享的精神,也趁這機(jī)會(huì)總結(jié)一下景鼠,所以寫(xiě)下這篇文章仲翎,文中有不對(duì)之處歡迎批評(píng)與指正。
本文將介紹:
- Protobuf序列化
- TCP拆包與粘包
- 長(zhǎng)連接握手認(rèn)證
- 心跳機(jī)制
- 重連機(jī)制
- 消息重發(fā)機(jī)制
- 讀寫(xiě)超時(shí)機(jī)制
- 離線消息
- 線程池
AIDL跨進(jìn)程通信
本想花一部分時(shí)間介紹一下利用AIDL實(shí)現(xiàn)多進(jìn)程通信铛漓,提升應(yīng)用彼菹悖活率,無(wú)奈這種方法在目前大部分Android新版本上已失效浓恶,而且也比較復(fù)雜玫坛,所以考慮再三,把AIDL這一部分去掉包晰,需要了解的童鞋可以私信我湿镀。
先來(lái)看看效果:
不想看文章的同學(xué)可以直接移步到Github fork源碼:github地址
接下來(lái)炕吸,讓我們進(jìn)入正題。
為什么使用TCP肠骆?
這里需要簡(jiǎn)單解釋一下算途,TCP/UDP/WebSocket的區(qū)別。
這里就很好地解釋了TCP/UDP的優(yōu)缺點(diǎn)和區(qū)別蚀腿,以及適用場(chǎng)景嘴瓤,簡(jiǎn)單地總結(jié)一下:
-
優(yōu)點(diǎn):
- TCP的優(yōu)點(diǎn)體現(xiàn)在穩(wěn)定、可靠上莉钙,在傳輸數(shù)據(jù)之前廓脆,會(huì)有三次握手來(lái)建立連接,而且在數(shù)據(jù)傳遞時(shí)磁玉,有確認(rèn)停忿、窗口、重傳蚊伞、擁塞控制機(jī)制席赂,在數(shù)據(jù)傳完之后,還會(huì)斷開(kāi)連接用來(lái)節(jié)約系統(tǒng)資源时迫。
- UDP的優(yōu)點(diǎn)體現(xiàn)在快颅停,比TCP稍安全,UDP沒(méi)有TCP擁有的各種機(jī)制掠拳,是一個(gè)無(wú)狀態(tài)的傳輸協(xié)議癞揉,所以傳遞數(shù)據(jù)非常快溺欧,沒(méi)有TCP的這些機(jī)制喊熟,被攻擊利用的機(jī)制就少一些,但是也無(wú)法避免被攻擊姐刁。
-
缺點(diǎn):
- TCP缺點(diǎn)就是慢芥牌,效率低,占用系統(tǒng)資源高聂使,易被攻擊胳泉,TCP在傳遞數(shù)據(jù)之前要先建立連接,這會(huì)消耗時(shí)間岩遗,而且在數(shù)據(jù)傳遞時(shí)扇商,確認(rèn)機(jī)制、重傳機(jī)制宿礁、擁塞機(jī)制等都會(huì)消耗大量時(shí)間案铺,而且要在每臺(tái)設(shè)備上維護(hù)所有的傳輸連接。
- UDP缺點(diǎn)就是不可靠梆靖,不穩(wěn)定控汉,因?yàn)闆](méi)有TCP的那些機(jī)制笔诵,UDP在傳輸數(shù)據(jù)時(shí),如果網(wǎng)絡(luò)質(zhì)量不好姑子,就會(huì)很容易丟包乎婿,造成數(shù)據(jù)的缺失。
-
適用場(chǎng)景:
- TCP:當(dāng)對(duì)網(wǎng)絡(luò)通訊質(zhì)量有要求時(shí)街佑,比如HTTP谢翎、HTTPS、FTP等傳輸文件的協(xié)議沐旨, POP森逮、SMTP等郵件傳輸?shù)膮f(xié)議。
- UDP:對(duì)網(wǎng)絡(luò)通訊質(zhì)量要求不高時(shí)磁携,要求網(wǎng)絡(luò)通訊速度要快的場(chǎng)景褒侧。
至于WebSocket,后續(xù)可能會(huì)專門寫(xiě)一篇文章來(lái)介紹谊迄。
綜上所述闷供,決定采用TCP協(xié)議。
為什么使用Protobuf统诺?
對(duì)于App網(wǎng)絡(luò)傳輸協(xié)議这吻,我們比較常見(jiàn)的、可選的篙议,有三種,分別是json/xml/protobuf怠硼,老規(guī)矩鬼贱,我們先分別來(lái)看看這三種格式的優(yōu)缺點(diǎn):
-
優(yōu)點(diǎn):
- json優(yōu)點(diǎn)就是較XML格式更加小巧,傳輸效率較xml提高了很多香璃,可讀性還不錯(cuò)这难。
- xml優(yōu)點(diǎn)就是可讀性強(qiáng),解析方便葡秒。
- protobuf優(yōu)點(diǎn)就是傳輸效率快(據(jù)說(shuō)在數(shù)據(jù)量大的時(shí)候姻乓,傳輸效率比xml和json快10-20倍),序列化后體積相比Json和XML很小眯牧,支持跨平臺(tái)多語(yǔ)言蹋岩,消息格式升級(jí)和兼容性還不錯(cuò),序列化反序列化速度很快学少。
-
缺點(diǎn):
- json缺點(diǎn)就是傳輸效率也不是特別高(比xml快剪个,但比protobuf要慢很多)。
- xml缺點(diǎn)就是效率不高版确,資源消耗過(guò)大扣囊。
- protobuf缺點(diǎn)就是使用不太方便乎折。
在一個(gè)需要大量的數(shù)據(jù)傳輸?shù)膱?chǎng)景中,如果數(shù)據(jù)量很大侵歇,那么選擇protobuf可以明顯的減少數(shù)據(jù)量骂澄,減少網(wǎng)絡(luò)IO,從而減少網(wǎng)絡(luò)傳輸所消耗的時(shí)間惕虑》爻澹考慮到作為一個(gè)主打社交的產(chǎn)品,消息數(shù)據(jù)量會(huì)非常大枷遂,同時(shí)為了節(jié)約流量樱衷,所以采用protobuf是一個(gè)不錯(cuò)的選擇。
為什么使用Netty酒唉?
首先矩桂,我們來(lái)了解一下,Netty到底是個(gè)什么東西痪伦。網(wǎng)絡(luò)上找到的介紹:Netty是由JBOSS提供的基于Java NIO的開(kāi)源框架侄榴,Netty提供異步非阻塞、事件驅(qū)動(dòng)网沾、高性能癞蚕、高可靠、高可定制性的網(wǎng)絡(luò)應(yīng)用程序和工具辉哥,可用于開(kāi)發(fā)服務(wù)端和客戶端桦山。
-
為什么不用Java BIO?
- 一連接一線程醋旦,由于線程數(shù)是有限的恒水,所以這樣非常消耗資源,最終也導(dǎo)致它不能承受高并發(fā)連接的需求饲齐。
- 性能低钉凌,因?yàn)轭l繁的進(jìn)行上下文切換,導(dǎo)致CUP利用率低捂人。
- 可靠性差御雕,由于所有的IO操作都是同步的,即使是業(yè)務(wù)線程也如此滥搭,所以業(yè)務(wù)線程的IO操作也有可能被阻塞酸纲,這將導(dǎo)致系統(tǒng)過(guò)分依賴網(wǎng)絡(luò)的實(shí)時(shí)情況和外部組件的處理能力,可靠性大大降低瑟匆。
-
為什么不用Java NIO福青?
- NIO的類庫(kù)和API相當(dāng)復(fù)雜,使用它來(lái)開(kāi)發(fā),需要非常熟練地掌握Selector无午、ByteBuffer媒役、ServerSocketChannel、SocketChannel等宪迟。
- 需要很多額外的編程技能來(lái)輔助使用NIO,例如酣衷,因?yàn)镹IO涉及了Reactor線程模型,所以必須必須對(duì)多線程和網(wǎng)絡(luò)編程非常熟悉才能寫(xiě)出高質(zhì)量的NIO程序次泽。
- 想要有高可靠性穿仪,工作量和難度都非常的大,因?yàn)榉?wù)端需要面臨客戶端頻繁的接入和斷開(kāi)意荤、網(wǎng)絡(luò)閃斷啊片、半包讀寫(xiě)、失敗緩存玖像、網(wǎng)絡(luò)阻塞的問(wèn)題紫谷,這些將嚴(yán)重影響我們的可靠性,而使用原生NIO解決它們的難度相當(dāng)大捐寥。
- JDK NIO中著名的BUG--epoll空輪詢笤昨,當(dāng)select返回0時(shí),會(huì)導(dǎo)致Selector空輪詢而導(dǎo)致CUP100%握恳,官方表示JDK1.6之后修復(fù)了這個(gè)問(wèn)題瞒窒,其實(shí)只是發(fā)生的概率降低了,沒(méi)有根本上解決乡洼。
-
為什么用Netty崇裁?
- API使用簡(jiǎn)單,更容易上手束昵,開(kāi)發(fā)門檻低
- 功能強(qiáng)大拔稳,預(yù)置了多種編解碼功能,支持多種主流協(xié)議
- 定制能力高妻怎,可以通過(guò)ChannelHandler對(duì)通信框架進(jìn)行靈活地拓展
- 高性能,與目前多種NIO主流框架相比泞歉,Netty綜合性能最高
- 高穩(wěn)定性逼侦,解決了JDK NIO的BUG
- 經(jīng)歷了大規(guī)模的商業(yè)應(yīng)用考驗(yàn),質(zhì)量和可靠性都有很好的驗(yàn)證腰耙。
- 為什么不用第三方SDK榛丢,如:融云、環(huán)信挺庞、騰訊TIM晰赞?
這個(gè)就見(jiàn)仁見(jiàn)智了,有的時(shí)候,是因?yàn)楣镜募夹g(shù)選型問(wèn)題掖鱼,因?yàn)橛玫谌降腟DK然走,意味著消息數(shù)據(jù)需要存儲(chǔ)到第三方的服務(wù)器上,再者戏挡,可擴(kuò)展性芍瑞、靈活性肯定沒(méi)有自己開(kāi)發(fā)的要好,還有一個(gè)小問(wèn)題褐墅,就是收費(fèi)拆檬。比如,融云免費(fèi)版只支持100個(gè)注冊(cè)用戶妥凳,超過(guò)100就要收費(fèi)竟贯,群聊支持人數(shù)有限制等等...
Mina其實(shí)跟Netty很像,大部分API都相同逝钥,因?yàn)槭峭粋€(gè)作者開(kāi)發(fā)的屑那。但感覺(jué)Mina沒(méi)有Netty成熟,在使用Netty的過(guò)程中晌缘,出了問(wèn)題很輕易地可以找到解決方案齐莲,所以,Netty是一個(gè)不錯(cuò)的選擇磷箕。
好了选酗,廢話不多說(shuō),直接開(kāi)始吧岳枷。
準(zhǔn)備工作
-
首先芒填,我們新建一個(gè)Project,在Project里面再新建一個(gè)Android Library空繁,Module名稱暫且叫做im_lib殿衰,如圖所示:
-
然后,分析一下我們的消息結(jié)構(gòu)盛泡,每條消息應(yīng)該會(huì)有一個(gè)消息唯一id闷祥,發(fā)送者id,接收者id傲诵,消息類型凯砍,發(fā)送時(shí)間等,經(jīng)過(guò)分析拴竹,整理出一個(gè)通用的消息類型悟衩,如下:
- msgId 消息id
- fromId 發(fā)送者id
- toId 接收者id
- msgType 消息類型
- msgContentType 消息內(nèi)容類型
- timestamp 消息時(shí)間戳
- statusReport 狀態(tài)報(bào)告
- extend 擴(kuò)展字段
根據(jù)上述所示,我整理了一個(gè)思維導(dǎo)圖栓拜,方便大家參考:
這是基礎(chǔ)部分座泳,當(dāng)然惠昔,大家也可以根據(jù)自己需要自定義比較適合自己的消息結(jié)構(gòu)。我們根據(jù)自定義的消息類型來(lái)編寫(xiě)proto文件挑势。
然后執(zhí)行命令(我用的mac镇防,windows命令應(yīng)該也差不多):
然后就會(huì)看到,在和proto文件同級(jí)目錄下薛耻,會(huì)生成一個(gè)java類营罢,這個(gè)就是我們需要用到的東東:
我們打開(kāi)瞄一眼:
東西比較多,不用去管饼齿,這是google為我們生成的protobuf類饲漾,直接用就行,怎么用呢缕溉?直接用這個(gè)類文件考传,拷到我們開(kāi)始指定的項(xiàng)目包路徑下就可以啦:
添加依賴后,可以看到证鸥,MessageProtobuf類文件已經(jīng)沒(méi)有報(bào)錯(cuò)了僚楞,順便把netty的jar包也導(dǎo)進(jìn)來(lái)一下,還有fastjson的:
建議用netty-all-x.x.xx.Final的jar包枉层,后續(xù)熟悉了泉褐,可以用精簡(jiǎn)的jar包。至此鸟蜡,準(zhǔn)備工作已結(jié)束膜赃,下面,我們來(lái)編寫(xiě)java代碼揉忘,實(shí)現(xiàn)即時(shí)通訊的功能跳座。
封裝
為什么需要封裝呢?說(shuō)白了泣矛,就是為了解耦疲眷,為了方便日后切換到不同框架實(shí)現(xiàn),而無(wú)需到處修改調(diào)用的地方您朽。舉個(gè)栗子狂丝,比如Android早期比較流行的圖片加載框架是Universal ImageLoader,后期因?yàn)槟承┰蚧┳埽髡咄V沽司S護(hù)該項(xiàng)目几颜,目前比較流行的圖片加載框架是Picasso或Glide,因?yàn)閳D片加載功能可能調(diào)用的地方非常多魂奥,如果不作一些封裝仆潮,早期使用了Universal ImageLoader的話已卸,現(xiàn)在需要切換到Glide遣蚀,那改動(dòng)量將非常非常大,而且還很有可能會(huì)有遺漏中燥,風(fēng)險(xiǎn)度非常高。
那么圃郊,有什么解決方案呢薪寓?
很簡(jiǎn)單,我們可以用工廠設(shè)計(jì)模式進(jìn)行一些封裝炮赦,工廠模式有三種:簡(jiǎn)單工廠模式怜跑、抽象工廠模式、工廠方法模式吠勘。在這里性芬,我采用工廠方法模式進(jìn)行封裝,具體區(qū)別剧防,可以參見(jiàn):通俗講講我對(duì)簡(jiǎn)單工廠植锉、工廠方法、抽象工廠三種設(shè)計(jì)模式的理解
我們分析一下峭拘,ims(IM Service俊庇,下文簡(jiǎn)稱ims)應(yīng)該是有初始化、建立連接鸡挠、重連辉饱、關(guān)閉連接、釋放資源拣展、判斷長(zhǎng)連接是否關(guān)閉彭沼、發(fā)送消息等功能,基于上述分析瞎惫,我們可以進(jìn)行一個(gè)接口抽象:
OnEventListener是與應(yīng)用層交互的listener:
IMConnectStatusCallback是ims連接狀態(tài)回調(diào)監(jiān)聽(tīng)器:
然后寫(xiě)一個(gè)Netty tcp實(shí)現(xiàn)類:
接下來(lái)溜腐,寫(xiě)一個(gè)工廠方法:
封裝部分到此結(jié)束,接下來(lái)瓜喇,就是實(shí)現(xiàn)了挺益。
初始化
我們先實(shí)現(xiàn)init(Vector<String> serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些參數(shù)乘寒,以及進(jìn)行第一次連接等:
其中望众,MsgDispatcher是消息轉(zhuǎn)發(fā)器,負(fù)責(zé)將接收到的消息轉(zhuǎn)發(fā)到應(yīng)用層:
ExecutorServiceFactory是線程池工廠伞辛,負(fù)責(zé)調(diào)度重連及心跳線程:
連接及重連
resetConnect()方法作為連接的起點(diǎn)烂翰,首次連接以及重連邏輯,都是在resetConnect()方法進(jìn)行邏輯處理蚤氏,我們來(lái)瞄一眼:
可以看到甘耿,非首次進(jìn)行連接,也就是連接一個(gè)周期失敗后竿滨,進(jìn)行重連時(shí)佳恬,會(huì)先讓線程休眠一段時(shí)間捏境,因?yàn)檫@個(gè)時(shí)候也許網(wǎng)絡(luò)狀況不太好,接著毁葱,判斷ims是否已關(guān)閉或者是否正在進(jìn)行重連操作垫言,由于重連操作是在子線程執(zhí)行,為了避免重復(fù)重連倾剿,需要進(jìn)行一些并發(fā)處理筷频。開(kāi)始重連任務(wù)后,分四個(gè)步驟執(zhí)行:
- 改變重連狀態(tài)標(biāo)識(shí)
- 回調(diào)連接狀態(tài)到應(yīng)用層
- 關(guān)閉之前打開(kāi)的連接channel
- 利用線程池執(zhí)行一個(gè)新的重連任務(wù)
ResetConnectRunnable是重連任務(wù)前痘,核心的重連邏輯都放到這里執(zhí)行:
toServer()是真正連接服務(wù)器的地方:
initBootstrap()是初始化Netty Bootstrap:
注:NioEventLoopGroup線程數(shù)設(shè)置為4凛捏,可以滿足QPS是一百多萬(wàn)的情況了,至于應(yīng)用如果需要承受上千萬(wàn)上億流量的芹缔,需要另外調(diào)整線程數(shù)葵袭。參考自:netty實(shí)戰(zhàn)之百萬(wàn)級(jí)流量NioEventLoopGroup線程數(shù)配置
接著,我們來(lái)看看TCPChannelInitializerHanlder:
其中乖菱,ProtobufEncoder和ProtobufDecoder是添加對(duì)protobuf的支持坡锡,LoginAuthRespHandler是接收到服務(wù)端握手認(rèn)證消息響應(yīng)的處理handler,HeartbeatRespHandler是接收到服務(wù)端心跳消息響應(yīng)的處理handler窒所,TCPReadHandler是接收到服務(wù)端其它消息后的處理handler鹉勒,先不去管,我們重點(diǎn)來(lái)分析下LengthFieldPrepender和LengthFieldBasedFrameDecoder吵取,這就需要引申到TCP的拆包與粘包啦禽额。
TCP的拆包與粘包
-
什么是TCP拆包?為什么會(huì)出現(xiàn)TCP拆包皮官?
簡(jiǎn)單地說(shuō)脯倒,我們都知道TCP是以“流”的形式進(jìn)行數(shù)據(jù)傳輸?shù)模襎CP為提高性能捺氢,發(fā)送端會(huì)將需要發(fā)送的數(shù)據(jù)刷入緩沖區(qū)藻丢,等待緩沖區(qū)滿了之后,再將緩沖區(qū)中的數(shù)據(jù)發(fā)送給接收方摄乒,同理悠反,接收方也會(huì)有緩沖區(qū)這樣的機(jī)制,來(lái)接收數(shù)據(jù)馍佑。
拆包就是在socket讀取時(shí)斋否,沒(méi)有完整地讀取一個(gè)數(shù)據(jù)包,只讀取一部分拭荤。 -
什么是TCP粘包茵臭?為什么會(huì)出現(xiàn)TCP粘包?
同上舅世。
粘包就是在socket讀取時(shí)旦委,讀到了實(shí)際意義上的兩個(gè)或多個(gè)數(shù)據(jù)包的內(nèi)容踊沸,同時(shí)將其作為一個(gè)數(shù)據(jù)包進(jìn)行處理。
引用網(wǎng)上一張圖片來(lái)解釋一下在TCP出現(xiàn)拆包社证、粘包以及正常狀態(tài)下的三種情況,如侵請(qǐng)聯(lián)系我刪除:
了解了TCP出現(xiàn)拆包/粘包的原因,那么奕短,如何解決呢宜肉?通常來(lái)說(shuō),有以下四種解決方式:
- 消息定長(zhǎng)
- 用回車換行符作為消息結(jié)束標(biāo)志
- 用特殊分隔符作為消息結(jié)束標(biāo)志翎碑,如\t谬返、\n等,回車換行符其實(shí)就是特殊分隔符的一種日杈。
- 將消息分為消息頭和消息體遣铝,在消息頭中用字段標(biāo)識(shí)消息總長(zhǎng)度。
netty針對(duì)以上四種場(chǎng)景莉擒,給我們封裝了以下四種對(duì)應(yīng)的解碼器:
- FixedLengthFrameDecoder酿炸,定長(zhǎng)消息解碼器
- LineBasedFrameDecoder,回車換行符消息解碼器
- DelimiterBasedFrameDecoder涨冀,特殊分隔符消息解碼器
- LengthFieldBasedFrameDecoder填硕,自定義長(zhǎng)度消息解碼器。
我們用到的就是LengthFieldBasedFrameDecoder自定義長(zhǎng)度消息解碼器鹿鳖,同時(shí)配合LengthFieldPrepender編碼器使用扁眯,關(guān)于參數(shù)配置,建議參考netty--最通用TCP黏包解決方案:LengthFieldBasedFrameDecoder和LengthFieldPrepender這篇文章翅帜,講解得比較細(xì)致姻檀。我們配置的是消息頭長(zhǎng)度為2個(gè)字節(jié),所以消息包的最大長(zhǎng)度需要小于65536個(gè)字節(jié)涝滴,netty會(huì)把消息內(nèi)容長(zhǎng)度存放消息頭的字段里施敢,接收方可以根據(jù)消息頭的字段拿到此條消息總長(zhǎng)度,當(dāng)然狭莱,netty提供的LengthFieldBasedFrameDecoder已經(jīng)封裝好了處理邏輯僵娃,我們只需要配置lengthFieldOffset、lengthFieldLength腋妙、lengthAdjustment默怨、initialBytesToStrip即可,這樣就可以解決TCP的拆包與粘包骤素,這也就是netty相較于原生nio的便捷性匙睹,原生nio需要自己處理拆包/粘包等問(wèn)題愚屁。
長(zhǎng)連接握手認(rèn)證
接著,我們來(lái)看看LoginAuthHandler和HeartbeatRespHandler:
-
LoginAuthRespHandler是當(dāng)客戶端與服務(wù)端長(zhǎng)連接建立成功后痕檬,客戶端主動(dòng)向服務(wù)端發(fā)送一條登錄認(rèn)證消息霎槐,帶入與當(dāng)前用戶相關(guān)的參數(shù),比如token梦谜,服務(wù)端收到此消息后丘跌,到數(shù)據(jù)庫(kù)查詢?cè)撚脩粜畔ⅲ绻呛戏ㄓ行У挠脩粞渥瑒t返回一條登錄成功消息給該客戶端闭树,反之,返回一條登錄失敗消息給該客戶端荒澡,這里报辱,就是在接收到服務(wù)端返回的登錄狀態(tài)后的處理handler,比如:
可以看到单山,當(dāng)接收到服務(wù)端握手消息響應(yīng)后碍现,會(huì)從擴(kuò)展字段取出status,如果status=1米奸,則代表握手成功鸵赫,這個(gè)時(shí)候就先主動(dòng)向服務(wù)端發(fā)送一條心跳消息,然后利用Netty的IdleStateHandler讀寫(xiě)超時(shí)機(jī)制躏升,定期向服務(wù)端發(fā)送心跳消息辩棒,維持長(zhǎng)連接,以及檢測(cè)長(zhǎng)連接是否還存在等膨疏。 -
HeartbeatRespHandler是當(dāng)客戶端接收到服務(wù)端登錄成功的消息后一睁,主動(dòng)向服務(wù)端發(fā)送一條心跳消息,心跳消息可以是一個(gè)空包佃却,消息包體越小越好者吁,服務(wù)端收到客戶端的心跳包后,原樣返回給客戶端饲帅,這里复凳,就是收到服務(wù)端返回的心跳消息響應(yīng)的處理handler,比如:
這個(gè)就比較簡(jiǎn)單灶泵,收到心跳消息響應(yīng)育八,無(wú)需任務(wù)處理,直接打印一下方便我們分析即可赦邻。
心跳機(jī)制及讀寫(xiě)超時(shí)機(jī)制
心跳包是定期發(fā)送髓棋,也可以自己定義一個(gè)周期,比如Android微信智能心跳方案,為了簡(jiǎn)單按声,此處規(guī)定應(yīng)用在前臺(tái)時(shí)膳犹,8秒發(fā)送一個(gè)心跳包,切換到后臺(tái)時(shí)签则,30秒發(fā)送一次须床,根據(jù)自己的實(shí)際情況修改一下即可。心跳包用于維持長(zhǎng)連接以及檢測(cè)長(zhǎng)連接是否斷開(kāi)等渐裂。
接著豺旬,我們利用Netty的讀寫(xiě)超時(shí)機(jī)制,來(lái)實(shí)現(xiàn)一個(gè)心跳消息管理handler:
可以看到芯义,利用userEventTriggered()方法回調(diào),通過(guò)IdleState類型妻柒,可以判斷讀超時(shí)/寫(xiě)超時(shí)/讀寫(xiě)超時(shí)扛拨,這個(gè)在添加IdleStateHandler時(shí)可以配置,下面會(huì)貼上代碼举塔。首先我們可以在READER_IDLE事件里绑警,檢測(cè)是否在規(guī)定時(shí)間內(nèi)沒(méi)有收到服務(wù)端心跳包響應(yīng),如果是央渣,那就觸發(fā)重連操作计盒。在WRITER_IDEL事件可以檢測(cè)客戶端是否在規(guī)定時(shí)間內(nèi)沒(méi)有向服務(wù)端發(fā)送心跳包,如果是芽丹,那就主動(dòng)發(fā)送一個(gè)心跳包北启。發(fā)送心跳包是在子線程中執(zhí)行,我們可以利用之前寫(xiě)的work線程池進(jìn)行線程管理拔第。
addHeartbeatHandler()代碼如下:
從圖上可看到咕村,在IdleStateHandler里,配置的讀超時(shí)為心跳間隔時(shí)長(zhǎng)的3倍蚊俺,也就是3次心跳沒(méi)有響應(yīng)時(shí)懈涛,則認(rèn)為長(zhǎng)連接已斷開(kāi),觸發(fā)重連操作泳猬。寫(xiě)超時(shí)則為心跳間隔時(shí)長(zhǎng)批钠,意味著每隔heartbeatInterval會(huì)發(fā)送一個(gè)心跳包。讀寫(xiě)超時(shí)沒(méi)用到得封,所以配置為0埋心。
onConnectStatusCallback(int connectStatus)為連接狀態(tài)回調(diào),以及一些公共邏輯處理:
連接成功后忙上,立即發(fā)送一條握手消息踩窖,再次梳理一下整體流程:
- 客戶端根據(jù)服務(wù)端返回的host及port,進(jìn)行第一次連接晨横。
- 連接成功后洋腮,客戶端向服務(wù)端發(fā)送一條握手認(rèn)證消息(1001)
- 服務(wù)端在收到客戶端的握手認(rèn)證消息后箫柳,從擴(kuò)展字段里取出用戶token,到本地?cái)?shù)據(jù)庫(kù)校驗(yàn)合法性啥供。
- 校驗(yàn)完成后悯恍,服務(wù)端把校驗(yàn)結(jié)果通過(guò)1001消息返回給客戶端,也就是握手消息響應(yīng)伙狐。
- 客戶端收到服務(wù)端的握手消息響應(yīng)后涮毫,從擴(kuò)展字段取出校驗(yàn)結(jié)果。若校驗(yàn)成功贷屎,客戶端向服務(wù)端發(fā)送一條心跳消息(1002)罢防,然后進(jìn)入心跳發(fā)送周期,定期間隔向服務(wù)端發(fā)送心跳消息唉侄,維持長(zhǎng)連接以及實(shí)時(shí)檢測(cè)鏈路可用性咒吐,若發(fā)現(xiàn)鏈路不可用,等待一段時(shí)間觸發(fā)重連操作属划,重連成功后恬叹,重新開(kāi)始握手/心跳的邏輯。
看看TCPReadHandler收到消息是怎么處理的:
可以看到同眯,在channelInactive()及exceptionCaught()方法都觸發(fā)了重連绽昼,channelInactive()方法在當(dāng)鏈路斷開(kāi)時(shí)會(huì)調(diào)用,exceptionCaught()方法在當(dāng)出現(xiàn)異常時(shí)會(huì)觸發(fā)须蜗,另外硅确,還有諸如channelUnregistered()、channelReadComplete()等方法可以重寫(xiě)明肮,在這里就不貼了疏魏,相信聰明的你一眼就能看出方法的作用。
我們仔細(xì)看一下channelRead()方法的邏輯晤愧,在if判斷里大莫,先判斷消息類型,如果是服務(wù)端返回的消息發(fā)送狀態(tài)報(bào)告類型官份,則判斷消息是否發(fā)送成功只厘,如果發(fā)送成功,從超時(shí)管理器中移除舅巷,這個(gè)超時(shí)管理器是干嘛的呢羔味?下面講到消息重發(fā)機(jī)制的時(shí)候會(huì)詳細(xì)地講。在else里钠右,收到其他消息后赋元,會(huì)立馬給服務(wù)端返回一個(gè)消息接收狀態(tài)報(bào)告,告訴服務(wù)端,這條消息我已經(jīng)收到了搁凸,這個(gè)動(dòng)作媚值,對(duì)于后續(xù)需要做的離線消息會(huì)有作用。如果不需要支持離線消息功能护糖,這一步可以省略褥芒。最后,調(diào)用消息轉(zhuǎn)發(fā)器嫡良,把接收到的消息轉(zhuǎn)發(fā)到應(yīng)用層即可锰扶。
代碼寫(xiě)了這么多,我們先來(lái)看看運(yùn)行后的效果寝受,先貼上缺失的消息發(fā)送代碼及ims關(guān)閉代碼以及一些默認(rèn)配置項(xiàng)的代碼坷牛。
發(fā)送消息:
關(guān)閉ims:
ims默認(rèn)配置:
還有,應(yīng)用層實(shí)現(xiàn)的ims client啟動(dòng)器:
由于代碼有點(diǎn)多很澄,不太方便全部貼上京闰,如果有興趣可以下載demo體驗(yàn)。
額痴怨,對(duì)了忙干,還有一個(gè)簡(jiǎn)易的服務(wù)端代碼器予,如下:
調(diào)試
我們先來(lái)看看連接及重連部分(由于錄制gif比較麻煩浪藻,體積較大,所以我先把重連間隔調(diào)小成3秒乾翔,方便看效果)爱葵。
-
啟動(dòng)服務(wù)端:
-
啟動(dòng)客戶端:
可以看到,正常的情況下已經(jīng)連接成功了反浓,接下來(lái)萌丈,我們來(lái)試一下異常情況,比如服務(wù)端沒(méi)啟動(dòng)雷则,看看客戶端的重連情況:
這次我們先啟動(dòng)的是客戶端辆雾,可以看到連接失敗后一直在進(jìn)行重連,由于錄制gif比較麻煩月劈,在第三次連接失敗后度迂,我啟動(dòng)了服務(wù)端,這個(gè)時(shí)候客戶端就會(huì)重連成功猜揪。
然后惭墓,我們?cè)賮?lái)調(diào)試一下握手認(rèn)證消息即心跳消息:
可以看到,長(zhǎng)連接建立成功后而姐,客戶端會(huì)給服務(wù)端發(fā)送一條握手認(rèn)證消息(1001)腊凶,服務(wù)端收到握手認(rèn)證消息會(huì),給客戶端返回了一條握手認(rèn)證狀態(tài)消息,客戶端收到握手認(rèn)證狀態(tài)消息后钧萍,即啟動(dòng)心跳機(jī)制褐缠。gif不太好演示,下載demo就可以直觀地看到划煮。
接下來(lái)送丰,在講完消息重發(fā)機(jī)制及離線消息后,我會(huì)在應(yīng)用層做一些簡(jiǎn)單的封裝弛秋,以及在模擬器上運(yùn)行器躏,這樣就可以很直觀地看到運(yùn)行效果。
消息重發(fā)機(jī)制
消息重發(fā)蟹略,顧名思義登失,即使對(duì)發(fā)送失敗的消息進(jìn)行重發(fā)⊥诰妫考慮到網(wǎng)絡(luò)環(huán)境的不穩(wěn)定性揽浙、多變性(比如從進(jìn)入電梯、進(jìn)入地鐵意敛、移動(dòng)網(wǎng)絡(luò)切換到wifi等)馅巷,在消息發(fā)送的時(shí)候,發(fā)送失敗的概率其實(shí)不小草姻,這時(shí)消息重發(fā)機(jī)制就很有必要了钓猬。
我們先來(lái)看看實(shí)現(xiàn)的代碼邏輯。
MsgTimeoutTimer:
MsgTimeoutTimerManager:
然后撩独,我們看看收消息的TCPReadHandler的改造:
最后敞曹,看看發(fā)送消息的改造:
說(shuō)一下邏輯吧:發(fā)送消息時(shí),除了心跳消息综膀、握手消息澳迫、狀態(tài)報(bào)告消息外,消息都加入消息發(fā)送超時(shí)管理器剧劝,立馬開(kāi)啟一個(gè)定時(shí)器橄登,比如每隔5秒執(zhí)行一次,共執(zhí)行3次讥此,在這個(gè)周期內(nèi)拢锹,如果消息沒(méi)有發(fā)送成功,會(huì)進(jìn)行3次重發(fā)暂论,達(dá)到3次重發(fā)后如果還是沒(méi)有發(fā)送成功面褐,那就放棄重發(fā),移除該消息取胎,同時(shí)通過(guò)消息轉(zhuǎn)發(fā)器通知應(yīng)用層展哭,由應(yīng)用層決定是否再次重發(fā)湃窍。如果消息發(fā)送成功,服務(wù)端會(huì)返回一個(gè)消息發(fā)送狀態(tài)報(bào)告匪傍,客戶端收到該狀態(tài)報(bào)告后您市,從消息發(fā)送超時(shí)管理器移除該消息,同時(shí)停止該消息對(duì)應(yīng)的定時(shí)器即可役衡。
另外茵休,在用戶握手認(rèn)證成功時(shí),應(yīng)該檢查消息發(fā)送超時(shí)管理器里是否有發(fā)送超時(shí)的消息手蝎,如果有榕莺,則全部重發(fā):
離線消息
由于離線消息機(jī)制,需要服務(wù)端數(shù)據(jù)庫(kù)及緩存上的配合棵介,代碼就不貼了钉鸯,太多太多,我簡(jiǎn)單說(shuō)一下實(shí)現(xiàn)思路吧:
客戶端A發(fā)送消息到客戶端B邮辽,消息會(huì)先到服務(wù)端唠雕,由服務(wù)端進(jìn)行中轉(zhuǎn)。這個(gè)時(shí)候吨述,客戶端B存在兩種情況:
- 1.長(zhǎng)連接正常岩睁,就是客戶端網(wǎng)絡(luò)環(huán)境良好,手機(jī)有電揣云,應(yīng)用處在打開(kāi)的情況聋丝。
- 2.廢話敌蚜,那肯定就是長(zhǎng)連接不正惩爰梗咯奴艾。這種情況有很多種原因亿笤,比如wifi不可用翎迁、用戶進(jìn)入了地鐵或電梯等網(wǎng)絡(luò)不好的場(chǎng)所、應(yīng)用沒(méi)打開(kāi)或已退出登錄等净薛,總的來(lái)說(shuō)汪榔,就是沒(méi)有辦法正常接收消息。
如果是長(zhǎng)連接正常肃拜,那沒(méi)什么可說(shuō)的痴腌,服務(wù)端直接轉(zhuǎn)發(fā)即可。
如果長(zhǎng)連接不正常燃领,需要這樣處理:服務(wù)端接收到客戶端A發(fā)送給客戶端B的消息后士聪,先給客戶端A回復(fù)一條狀態(tài)報(bào)告,告訴客戶端A猛蔽,我已經(jīng)收到消息剥悟,這個(gè)時(shí)候灵寺,客戶端A就不用管了,消息只要到達(dá)服務(wù)端即可区岗。然后略板,服務(wù)端先嘗試把消息轉(zhuǎn)發(fā)到客戶端B,如果這個(gè)時(shí)候客戶端B收到服務(wù)端轉(zhuǎn)發(fā)過(guò)來(lái)的消息慈缔,需要立馬給服務(wù)端回一條狀態(tài)報(bào)告叮称,告訴服務(wù)端,我已經(jīng)收到消息藐鹤,服務(wù)端在收到客戶端B返回的消息接收狀態(tài)報(bào)告后瓤檐,即認(rèn)為此消息已經(jīng)正常發(fā)送,不需要再存庫(kù)娱节。如果客戶端B不在線距帅,服務(wù)端在做轉(zhuǎn)發(fā)的時(shí)候,并沒(méi)有收到客戶端B返回的消息接收狀態(tài)報(bào)告括堤,那么碌秸,這條消息就應(yīng)該存到數(shù)據(jù)庫(kù),直到客戶端B上線后悄窃,也就是長(zhǎng)連接建立成功后讥电,客戶端B主動(dòng)向服務(wù)端發(fā)送一條離線消息詢問(wèn),服務(wù)端在收到離線消息詢問(wèn)后轧抗,到數(shù)據(jù)庫(kù)或緩存去查客戶端B的所有離線消息恩敌,并分批次返回,客戶端B在收到服務(wù)端的離線消息返回后横媚,取出消息id(若有多條就取id集合)纠炮,通過(guò)離線消息應(yīng)答把消息id返回到服務(wù)端,服務(wù)端收到后灯蝴,根據(jù)消息id從數(shù)據(jù)庫(kù)把對(duì)應(yīng)的消息刪除即可恢口。
以上是單聊離線消息處理的情況,群聊有點(diǎn)不同穷躁,群聊的話耕肩,是需要服務(wù)端確認(rèn)群組內(nèi)所有用戶都收到此消息后,才能從數(shù)據(jù)庫(kù)刪除消息问潭,就說(shuō)這么多猿诸,如果需要細(xì)節(jié)的話,可以私信我狡忙。
不知不覺(jué)梳虽,NettyTcpClient中定義了很多變量,為了防止大家不明白變量的定義灾茁,還是貼上代碼吧:
應(yīng)用層封裝
這個(gè)就見(jiàn)仁見(jiàn)智啦窜觉,每個(gè)人代碼風(fēng)格不同是复,我把自己簡(jiǎn)單封裝的代碼貼上來(lái)吧:
MessageProcessor消息處理器:
IMSEventListener與ims交互的listener:
MessageBuilder消息轉(zhuǎn)換器:
AbstractMessageHandler抽象的消息處理handler,每個(gè)消息類型對(duì)應(yīng)不同的messageHandler:
SingleChatMessageHandler單聊消息處理handler:
GroupChatMessageHandler群聊消息處理handler:
MessageHandlerFactory消息handler工廠:
MessageType消息類型枚舉:
IMSConnectStatusListenerIMS連接狀態(tài)監(jiān)聽(tīng)器:
由于每個(gè)人代碼風(fēng)格不同竖螃,封裝代碼都有自己的思路淑廊,所以,在此就不過(guò)多講解特咆,只是把自己簡(jiǎn)單封裝的代碼全部貼上來(lái)季惩,作一個(gè)參考即可。只需要知道腻格,接收到消息時(shí)画拾,會(huì)回調(diào)OnEventListener的dispatchMsg(MessageProtobuf.Msg msg)方法:
發(fā)送消息需要調(diào)用imsClient的sendMsg(MessageProtobuf.Msg msg)方法:
即可,至于怎樣去封裝得更好菜职,大家自由發(fā)揮吧青抛。
最后,為了測(cè)試消息收發(fā)是否正常酬核,我們需要改動(dòng)一下服務(wù)端:
可以看到蜜另,當(dāng)有用戶握手成功后,會(huì)保存該用戶對(duì)應(yīng)的channel到容器里嫡意,給用戶發(fā)送消息時(shí)举瑰,根據(jù)用戶id從容器里取出對(duì)應(yīng)的channel,利用該channel發(fā)送消息蔬螟。當(dāng)用戶斷開(kāi)連接后此迅,會(huì)把該用戶對(duì)應(yīng)的channel從容器里移除掉。
運(yùn)行一下旧巾,看看效果吧:
- 首先耸序,啟動(dòng)服務(wù)端。
- 然后鲁猩,修改客戶端連接的ip地址為192.168.0.105(這是我本機(jī)的ip地址)坎怪,端口號(hào)為8855,fromId绳匀,也就是userId芋忿,定義成100001炸客,toId為100002疾棵,啟動(dòng)客戶端A。
- 再然后痹仙,fromId是尔,也就是userId,定義成100002开仰,toId為100001拟枚,啟動(dòng)客戶端B薪铜。
- 客戶端A給客戶端B發(fā)送消息,可以看到在客戶端B的下面恩溅,已經(jīng)接收到了消息隔箍。
- 用客戶端B給客戶端A發(fā)送消息,也可以看到在客戶端A的下面脚乡,也已經(jīng)接收到了消息蜒滩。
至于,消息收發(fā)測(cè)試成功奶稠。至于群聊或重連等功能俯艰,就不一一演示了,還是那句話锌订,下載demo體驗(yàn)一下吧竹握。。辆飘。
由于gif錄制體積較大啦辐,所以只能簡(jiǎn)單演示一下消息收發(fā),具體下載demo體驗(yàn)吧蜈项。昧甘。。
如果有需要應(yīng)用層UI實(shí)現(xiàn)(就是聊天頁(yè)及會(huì)話頁(yè)的封裝)的話战得,我再分享出來(lái)吧充边。
發(fā)現(xiàn)的bug
-
MsgTimeoutTimer:
這個(gè)bug是自己在檢查代碼時(shí)發(fā)現(xiàn)的,可能是連續(xù)熬幾天夜寫(xiě)文章魔怔了常侦。浇冰。。
修改如下:
一個(gè)人精力有限聋亡,大家在使用過(guò)程中肘习,如果發(fā)現(xiàn)其它bug,煩請(qǐng)告訴我坡倔,反正我是會(huì)虛心接受漂佩,堅(jiān)決不改,呸罪塔,一定改投蝉,一定改。另外征堪,歡迎fork瘩缆,期待大家與我一起完善。佃蚜。庸娱。
寫(xiě)在最后
終于寫(xiě)完了着绊,這篇文章大概寫(xiě)了10天左右,有很大部分的原因是自己有拖延癥熟尉,每次寫(xiě)完一小段归露,總靜不下心來(lái)寫(xiě)下去,導(dǎo)致一直拖到現(xiàn)在斤儿,以后得改改靶擦。第一次寫(xiě)技術(shù)分享文章,有很多地方也許邏輯不太清晰雇毫,由于篇幅有限玄捕,也只是貼了部分代碼,建議大家把源碼下載下來(lái)看看棚放。一直想寫(xiě)這篇文章枚粘,以前在網(wǎng)上也嘗試過(guò)找過(guò)很多im方面的文章,都找不到一篇比較完善的飘蚯,本文談不上完善馍迄,但包含的模塊很多,希望起到一個(gè)拋磚引玉的作用局骤,也期待著大家跟我一起發(fā)現(xiàn)更多的問(wèn)題并完善攀圈,最后,如果這篇文章對(duì)你有用峦甩,希望在github上給我一個(gè)star哈赘来。。凯傲。
應(yīng)大家要求犬辰,精簡(jiǎn)了netty-all-4.1.33.Final.jar包。原netty-all-4.1.33.Final.jar包大小為3.9M冰单,經(jīng)測(cè)試發(fā)現(xiàn)目前im_lib庫(kù)只需要用到以下jar包:
- netty-buffer-4.1.33.Final.jar
- netty-codec-4.1.33.Final.jar
- netty-common-4.1.33.Final.jar
- netty-handler-4.1.33.Final.jar
- netty-resolver-4.1.33.Final.jar
- netty-transport-4.1.33.Final.jar
所以幌缝,抽取以上jar包,重新打成了netty-tcp-4.1.33-1.0.jar诫欠,目前自測(cè)沒(méi)有問(wèn)題涵卵,如果發(fā)現(xiàn)bug,請(qǐng)告訴我荒叼,謝謝轿偎。
附上原jar及裁剪后jar包的大小對(duì)比:
代碼已更新到Github.
接下來(lái),會(huì)抽時(shí)間把下圖想寫(xiě)的文章都寫(xiě)了甩挫,沒(méi)有先后順序贴硫,想到哪就寫(xiě)到哪吧。伊者。英遭。
另外,創(chuàng)建了一個(gè)Android即時(shí)通訊技術(shù)交流QQ群:1015178804亦渗,有需要的同學(xué)可以加進(jìn)來(lái)挖诸,不懂的問(wèn)題,我會(huì)盡量解答法精,一起學(xué)習(xí)多律,一起成長(zhǎng)。
最新新開(kāi)了一個(gè)微信公眾號(hào)搂蜓,方便后續(xù)KulaChat發(fā)布一些系列文章狼荞,同時(shí)也是為了激勵(lì)自己寫(xiě)作。主要發(fā)布一些原創(chuàng)的Android IM相關(guān)的文章(也會(huì)包含其它方向)帮碰,不定時(shí)更新相味。感興趣的同學(xué)可以關(guān)注一下,謝謝殉挽。PS:感覺(jué)鴻洋大神提供的公眾號(hào)文章排版方式丰涉,感激不盡~~
The end.