寫在前面
一直想寫一篇關於im即時通訊分享的文章,無奈工作太忙,很難抽出時間。今天終於從公司離職了,打算好好休息幾天再重新找工作,趁時間空閒,決定靜下心來寫一篇文章,畢竟從前輩那裡學到了很多東西。工作了五年半,這三四年來一直在做社交相關的專案,有 直播、 即時通訊、 短視訊分享、 社群論壇 等產品,深知即時通訊技術在一個專案中的重要性,本著開源分享的精神,也趁這機會總結一下,所以寫下這篇文章,文中有不對之處歡迎批評與指正。
本文將介紹:
- Protobuf序列化
- TCP拆包與粘包
- 長連線握手認證
- 心跳機制
- 重連機制
- 訊息重發機制
- 讀寫超時機制
- 離線訊息
- 執行緒池
AIDL跨程式通訊
本想花一部分時間介紹一下利用AIDL實現多程式通訊,提升應用保活率,無奈這種方法在目前大部分Android新版本上已失效,而且也比較複雜,所以考慮再三,把AIDL這一部分去掉,需要了解的童鞋可以私信我。
接下來,讓我們進入正題。
為什麼使用TCP?
這裡需要簡單解釋一下,TCP/UDP/WebSocket的區別。
這裡就很好地解釋了TCP/UDP的優缺點和區別,以及適用場景,簡單地總結一下:
-
優點:
- TCP的優點體現在穩定、可靠上,在傳輸資料之前,會有三次握手來建立連線,而且在資料傳遞時,有確認、視窗、重傳、擁塞控制機制,在資料傳完之後,還會斷開連線用來節約系統資源。
- UDP的優點體現在快,比TCP稍安全,UDP沒有TCP擁有的各種機制,是一個無狀態的傳輸協議,所以傳遞資料非常快,沒有TCP的這些機制,被攻擊利用的機制就少一些,但是也無法避免被攻擊。
-
缺點:
- TCP缺點就是慢,效率低,佔用系統資源高,易被攻擊,TCP在傳遞資料之前要先建立連線,這會消耗時間,而且在資料傳遞時,確認機制、重傳機制、擁塞機制等都會消耗大量時間,而且要在每臺裝置上維護所有的傳輸連線。
- UDP缺點就是不可靠,不穩定,因為沒有TCP的那些機制,UDP在傳輸資料時,如果網路質量不好,就會很容易丟包,造成資料的缺失。
-
適用場景:
- TCP:當對網路通訊質量有要求時,比如HTTP、HTTPS、FTP等傳輸檔案的協議, POP、SMTP等郵件傳輸的協議。
- UDP:對網路通訊質量要求不高時,要求網路通訊速度要快的場景。
至於WebSocket,後續可能會專門寫一篇文章來介紹。 綜上所述,決定採用TCP協議。
為什麼使用Protobuf?
對於App網路傳輸協議,我們比較常見的、可選的,有三種,分別是json/xml/protobuf,老規矩,我們先分別來看看這三種格式的優缺點:
-
優點:
- json優點就是較XML格式更加小巧,傳輸效率較xml提高了很多,可讀性還不錯。
- xml優點就是可讀性強,解析方便。
- protobuf優點就是傳輸效率快(據說在資料量大的時候,傳輸效率比xml和json快10-20倍),序列化後體積相比Json和XML很小,支援跨平臺多語言,訊息格式升級和相容性還不錯,序列化反序列化速度很快。
-
缺點:
- json缺點就是傳輸效率也不是特別高(比xml快,但比protobuf要慢很多)。
- xml缺點就是效率不高,資源消耗過大。
- protobuf缺點就是使用不太方便。
在一個需要大量的資料傳輸的場景中,如果資料量很大,那麼選擇protobuf可以明顯的減少資料量,減少網路IO,從而減少網路傳輸所消耗的時間。考慮到作為一個主打社交的產品,訊息資料量會非常大,同時為了節約流量,所以採用protobuf是一個不錯的選擇。
為什麼使用Netty?
首先,我們來了解一下,Netty到底是個什麼東西。網路上找到的介紹:Netty是由JBOSS提供的基於Java NIO的開源框架,Netty提供非同步非阻塞、事件驅動、高效能、高可靠、高可定製性的網路應用程式和工具,可用於開發服務端和客戶端。
-
為什麼不用Java BIO?
- 一連線一執行緒,由於執行緒數是有限的,所以這樣非常消耗資源,最終也導致它不能承受高併發連線的需求。
- 效能低,因為頻繁的進行上下文切換,導致CUP利用率低。
- 可靠性差,由於所有的IO操作都是同步的,即使是業務執行緒也如此,所以業務執行緒的IO操作也有可能被阻塞,這將導致系統過分依賴網路的實時情況和外部元件的處理能力,可靠性大大降低。
-
為什麼不用Java NIO?
- NIO的類庫和API相當複雜,使用它來開發,需要非常熟練地掌握Selector、ByteBuffer、ServerSocketChannel、SocketChannel等。
- 需要很多額外的程式設計技能來輔助使用NIO,例如,因為NIO涉及了Reactor執行緒模型,所以必須必須對多執行緒和網路程式設計非常熟悉才能寫出高質量的NIO程式。
- 想要有高可靠性,工作量和難度都非常的大,因為服務端需要面臨客戶端頻繁的接入和斷開、網路閃斷、半包讀寫、失敗快取、網路阻塞的問題,這些將嚴重影響我們的可靠性,而使用原生NIO解決它們的難度相當大。
- JDK NIO中著名的BUG--epoll空輪詢,當select返回0時,會導致Selector空輪詢而導致CUP100%,官方表示JDK1.6之後修復了這個問題,其實只是發生的概率降低了,沒有根本上解決。
-
為什麼用Netty?
- API使用簡單,更容易上手,開發門檻低
- 功能強大,預置了多種編解碼功能,支援多種主流協議
- 定製能力高,可以通過ChannelHandler對通訊框架進行靈活地擴充
- 高效能,與目前多種NIO主流框架相比,Netty綜合效能最高
- 高穩定性,解決了JDK NIO的BUG
- 經歷了大規模的商業應用考驗,質量和可靠性都有很好的驗證。
以上摘自:為什麼要用Netty開發
- 為什麼不用第三方SDK,如:融雲、環信、騰訊TIM?
這個就見仁見智了,有的時候,是因為公司的技術選型問題,因為用第三方的SDK,意味著訊息資料需要儲存到第三方的伺服器上,再者,可擴充套件性、靈活性肯定沒有自己開發的要好,還有一個小問題,就是收費。比如,融雲免費版只支援100個註冊使用者,超過100就要收費,群聊支援人數有限制等等...
Mina其實跟Netty很像,大部分API都相同,因為是同一個作者開發的。但感覺Mina沒有Netty成熟,在使用Netty的過程中,出了問題很輕易地可以找到解決方案,所以,Netty是一個不錯的選擇。
好了,廢話不多說,直接開始吧。
準備工作
-
首先,我們新建一個Project,在Project裡面再新建一個Android Library,Module名稱暫且叫做im_lib,如圖所示:
-
然後,分析一下我們的訊息結構,每條訊息應該會有一個訊息唯一id,傳送者id,接收者id,訊息型別,傳送時間等,經過分析,整理出一個通用的訊息型別,如下:
- msgId 訊息id
- fromId 傳送者id
- toId 接收者id
- msgType 訊息型別
- msgContentType 訊息內容型別
- timestamp 訊息時間戳
- statusReport 狀態報告
- extend 擴充套件欄位
根據上述所示,我整理了一個思維導圖,方便大家參考:
這是基礎部分,當然,大家也可以根據自己需要自定義比較適合自己的訊息結構。我們根據自定義的訊息型別來編寫proto檔案。
然後執行命令(我用的mac,windows命令應該也差不多): 然後就會看到,在和proto檔案同級目錄下,會生成一個java類,這個就是我們需要用到的東東: 我們開啟瞄一眼: 東西比較多,不用去管,這是google為我們生成的protobuf類,直接用就行,怎麼用呢?直接用這個類檔案,拷到我們開始指定的專案包路徑下就可以啦: 新增依賴後,可以看到,MessageProtobuf類檔案已經沒有報錯了,順便把netty的jar包也導進來一下,還有fastjson的: 建議用netty-all-x.x.xx.Final的jar包,後續熟悉了,可以用精簡的jar包。至此,準備工作已結束,下面,我們來編寫java程式碼,實現即時通訊的功能。
封裝
為什麼需要封裝呢?說白了,就是為了解耦,為了方便日後切換到不同框架實現,而無需到處修改呼叫的地方。舉個栗子,比如Android早期比較流行的圖片載入框架是Universal ImageLoader,後期因為某些原因,原作者停止了維護該專案,目前比較流行的圖片載入框架是Picasso或Glide,因為圖片載入功能可能呼叫的地方非常多,如果不作一些封裝,早期使用了Universal ImageLoader的話,現在需要切換到Glide,那改動量將非常非常大,而且還很有可能會有遺漏,風險度非常高。
那麼,有什麼解決方案呢?
很簡單,我們可以用工廠設計模式進行一些封裝,工廠模式有三種:簡單工廠模式、抽象工廠模式、工廠方法模式。在這裡,我採用工廠方法模式進行封裝,具體區別,可以參見:通俗講講我對簡單工廠、工廠方法、抽象工廠三種設計模式的理解
我們分析一下,ims(IM Service,下文簡稱ims)應該是有初始化、建立連線、重連、關閉連線、釋放資源、判斷長連線是否關閉、傳送訊息等功能,基於上述分析,我們可以進行一個介面抽象:




然後寫一個Netty tcp實現類:


接下來,寫一個工廠方法:

封裝部分到此結束,接下來,就是實現了。
初始化
我們先實現init(Vector serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些引數,以及進行第一次連線等:

其中,MsgDispatcher是訊息轉發器,負責將接收到的訊息轉發到應用層:

ExecutorServiceFactory是執行緒池工廠,負責排程重連及心跳執行緒:



連線及重連
resetConnect()方法作為連線的起點,首次連線以及重連邏輯,都是在resetConnect()方法進行邏輯處理,我們來瞄一眼:

- 改變重連狀態標識
- 回撥連線狀態到應用層
- 關閉之前開啟的連線channel
- 利用執行緒池執行一個新的重連任務
ResetConnectRunnable是重連任務,核心的重連邏輯都放到這裡執行:



toServer()是真正連線伺服器的地方:

initBootstrap()是初始化Netty Bootstrap:

接著,我們來看看TCPChannelInitializerHanlder:

TCP的拆包與粘包
-
什麼是TCP拆包?為什麼會出現TCP拆包?
簡單地說,我們都知道TCP是以“流”的形式進行資料傳輸的,而且TCP為提高效能,傳送端會將需要傳送的資料刷入緩衝區,等待緩衝區滿了之後,再將緩衝區中的資料傳送給接收方,同理,接收方也會有緩衝區這樣的機制,來接收資料。
拆包就是在socket讀取時,沒有完整地讀取一個資料包,只讀取一部分。 -
什麼是TCP粘包?為什麼會出現TCP粘包?
同上。
粘包就是在socket讀取時,讀到了實際意義上的兩個或多個資料包的內容,同時將其作為一個資料包進行處理。
引用網上一張圖片來解釋一下在TCP出現拆包、粘包以及正常狀態下的三種情況,如侵請聯絡我刪除:

- 訊息定長
- 用回車換行符作為訊息結束標誌
- 用特殊分隔符作為訊息結束標誌,如\t、\n等,回車換行符其實就是特殊分隔符的一種。
- 將訊息分為訊息頭和訊息體,在訊息頭中用欄位標識訊息總長度。
netty針對以上四種場景,給我們封裝了以下四種對應的解碼器:
- FixedLengthFrameDecoder,定長訊息解碼器
- LineBasedFrameDecoder,回車換行符訊息解碼器
- DelimiterBasedFrameDecoder,特殊分隔符訊息解碼器
- LengthFieldBasedFrameDecoder,自定義長度訊息解碼器。
我們用到的就是LengthFieldBasedFrameDecoder自定義長度訊息解碼器,同時配合LengthFieldPrepender編碼器使用,關於引數配置,建議參考netty--最通用TCP黏包解決方案:LengthFieldBasedFrameDecoder和LengthFieldPrepender這篇文章,講解得比較細緻。我們配置的是訊息頭長度為2個位元組,所以訊息包的最大長度需要小於65536個位元組,netty會把訊息內容長度存放訊息頭的欄位裡,接收方可以根據訊息頭的欄位拿到此條訊息總長度,當然,netty提供的LengthFieldBasedFrameDecoder已經封裝好了處理邏輯,我們只需要配置lengthFieldOffset、lengthFieldLength、lengthAdjustment、initialBytesToStrip即可,這樣就可以解決TCP的拆包與粘包,這也就是netty相較於原生nio的便捷性,原生nio需要自己處理拆包/粘包等問題。
長連線握手認證
接著,我們來看看LoginAuthHandler和HeartbeatRespHandler:
-
LoginAuthRespHandler是當客戶端與服務端長連線建立成功後,客戶端主動向服務端傳送一條登入認證訊息,帶入與當前使用者相關的引數,比如token,服務端收到此訊息後,到資料庫查詢該使用者資訊,如果是合法有效的使用者,則返回一條登入成功訊息給該客戶端,反之,返回一條登入失敗訊息給該客戶端,這裡,就是在接收到服務端返回的登入狀態後的處理handler,比如:
可以看到,當接收到服務端握手訊息響應後,會從擴充套件欄位取出status,如果status=1,則代表握手成功,這個時候就先主動向服務端傳送一條心跳訊息,然後利用Netty的IdleStateHandler讀寫超時機制,定期向服務端傳送心跳訊息,維持長連線,以及檢測長連線是否還存在等。 -
HeartbeatRespHandler是當客戶端接收到服務端登入成功的訊息後,主動向服務端傳送一條心跳訊息,心跳訊息可以是一個空包,訊息包體越小越好,服務端收到客戶端的心跳包後,原樣返回給客戶端,這裡,就是收到服務端返回的心跳訊息響應的處理handler,比如:
這個就比較簡單,收到心跳訊息響應,無需任務處理,直接列印一下方便我們分析即可。
心跳機制及讀寫超時機制
心跳包是定期傳送,也可以自己定義一個週期,比如Android微信智慧心跳方案,為了簡單,此處規定應用在前臺時,8秒傳送一個心跳包,切換到後臺時,30秒傳送一次,根據自己的實際情況修改一下即可。心跳包用於維持長連線以及檢測長連線是否斷開等。
接著,我們利用Netty的讀寫超時機制,來實現一個心跳訊息管理handler:

addHeartbeatHandler()程式碼如下:

onConnectStatusCallback(int connectStatus)為連線狀態回撥,以及一些公共邏輯處理:

- 客戶端根據服務端返回的host及port,進行第一次連線。
- 連線成功後,客戶端向服務端傳送一條握手認證訊息(1001)
- 服務端在收到客戶端的握手認證訊息後,從擴充套件欄位裡取出使用者token,到本地資料庫校驗合法性。
- 校驗完成後,服務端把校驗結果通過1001訊息返回給客戶端,也就是握手訊息響應。
- 客戶端收到服務端的握手訊息響應後,從擴充套件欄位取出校驗結果。若校驗成功,客戶端向服務端傳送一條心跳訊息(1002),然後進入心跳傳送週期,定期間隔向服務端傳送心跳訊息,維持長連線以及實時檢測鏈路可用性,若發現鏈路不可用,等待一段時間觸發重連操作,重連成功後,重新開始握手/心跳的邏輯。
看看TCPReadHandler收到訊息是怎麼處理的:


我們仔細看一下channelRead()方法的邏輯,在if判斷裡,先判斷訊息型別,如果是服務端返回的訊息傳送狀態報告型別,則判斷訊息是否傳送成功,如果傳送成功,從超時管理器中移除,這個超時管理器是幹嘛的呢?下面講到訊息重發機制的時候會詳細地講。在else裡,收到其他訊息後,會立馬給服務端返回一個訊息接收狀態報告,告訴服務端,這條訊息我已經收到了,這個動作,對於後續需要做的離線訊息會有作用。如果不需要支援離線訊息功能,這一步可以省略。最後,呼叫訊息轉發器,把接收到的訊息轉發到應用層即可。
程式碼寫了這麼多,我們先來看看執行後的效果,先貼上缺失的訊息傳送程式碼及ims關閉程式碼以及一些預設配置項的程式碼。
傳送訊息:







除錯
我們先來看看連線及重連部分(由於錄製gif比較麻煩,體積較大,所以我先把重連間隔調小成3秒,方便看效果)。
- 啟動服務端:
- 啟動客戶端:
可以看到,正常的情況下已經連線成功了,接下來,我們來試一下異常情況,比如服務端沒啟動,看看客戶端的重連情況: 這次我們先啟動的是客戶端,可以看到連線失敗後一直在進行重連,由於錄製gif比較麻煩,在第三次連線失敗後,我啟動了服務端,這個時候客戶端就會重連成功。
然後,我們再來除錯一下握手認證訊息即心跳訊息:

接下來,在講完訊息重發機制及離線訊息後,我會在應用層做一些簡單的封裝,以及在模擬器上執行,這樣就可以很直觀地看到執行效果。
訊息重發機制
訊息重發,顧名思義,即使對傳送失敗的訊息進行重發。考慮到網路環境的不穩定性、多變性(比如從進入電梯、進入地鐵、行動網路切換到wifi等),在訊息傳送的時候,傳送失敗的概率其實不小,這時訊息重發機制就很有必要了。
我們先來看看實現的程式碼邏輯。
MsgTimeoutTimer:




然後,我們看看收訊息的TCPReadHandler的改造:


說一下邏輯吧:傳送訊息時,除了心跳訊息、握手訊息、狀態報告訊息外,訊息都加入訊息傳送超時管理器,立馬開啟一個定時器,比如每隔5秒執行一次,共執行3次,在這個週期內,如果訊息沒有傳送成功,會進行3次重發,達到3次重發後如果還是沒有傳送成功,那就放棄重發,移除該訊息,同時通過訊息轉發器通知應用層,由應用層決定是否再次重發。如果訊息傳送成功,服務端會返回一個訊息傳送狀態報告,客戶端收到該狀態報告後,從訊息傳送超時管理器移除該訊息,同時停止該訊息對應的定時器即可。
另外,在使用者握手認證成功時,應該檢查訊息傳送超時管理器裡是否有傳送超時的訊息,如果有,則全部重發:

離線訊息
由於離線訊息機制,需要服務端資料庫及快取上的配合,程式碼就不貼了,太多太多,我簡單說一下實現思路吧: 客戶端A傳送訊息到客戶端B,訊息會先到服務端,由服務端進行中轉。這個時候,客戶端B存在兩種情況:
- 1.長連線正常,就是客戶端網路環境良好,手機有電,應用處在開啟的情況。
- 2.廢話,那肯定就是長連線不正常咯。這種情況有很多種原因,比如wifi不可用、使用者進入了地鐵或電梯等網路不好的場所、應用沒開啟或已退出登入等,總的來說,就是沒有辦法正常接收訊息。
如果是長連線正常,那沒什麼可說的,服務端直接轉發即可。
如果長連線不正常,需要這樣處理:服務端接收到客戶端A傳送給客戶端B的訊息後,先給客戶端A回覆一條狀態報告,告訴客戶端A,我已經收到訊息,這個時候,客戶端A就不用管了,訊息只要到達服務端即可。然後,服務端先嚐試把訊息轉發到客戶端B,如果這個時候客戶端B收到服務端轉發過來的訊息,需要立馬給服務端回一條狀態報告,告訴服務端,我已經收到訊息,服務端在收到客戶端B返回的訊息接收狀態報告後,即認為此訊息已經正常傳送,不需要再存庫。如果客戶端B不線上,服務端在做轉發的時候,並沒有收到客戶端B返回的訊息接收狀態報告,那麼,這條訊息就應該存到資料庫,直到客戶端B上線後,也就是長連線建立成功後,客戶端B主動向服務端傳送一條離線訊息詢問,服務端在收到離線訊息詢問後,到資料庫或快取去查客戶端B的所有離線訊息,並分批次返回,客戶端B在收到服務端的離線訊息返回後,取出訊息id(若有多條就取id集合),通過離線訊息應答把訊息id返回到服務端,服務端收到後,根據訊息id從資料庫把對應的訊息刪除即可。
以上是單聊離線訊息處理的情況,群聊有點不同,群聊的話,是需要服務端確認群組內所有使用者都收到此訊息後,才能從資料庫刪除訊息,就說這麼多,如果需要細節的話,可以私信我。
不知不覺,NettyTcpClient中定義了很多變數,為了防止大家不明白變數的定義,還是貼上程式碼吧:

應用層封裝
這個就見仁見智啦,每個人程式碼風格不同,我把自己簡單封裝的程式碼貼上來吧:
MessageProcessor訊息處理器:
















最後,為了測試訊息收發是否正常,我們需要改動一下服務端:





執行一下,看看效果吧:

- 首先,啟動服務端。
- 然後,修改客戶端連線的ip地址為192.168.0.105(這是我本機的ip地址),埠號為8855,fromId,也就是userId,定義成100001,toId為100002,啟動客戶端A。
- 再然後,fromId,也就是userId,定義成100002,toId為100001,啟動客戶端B。
- 客戶端A給客戶端B傳送訊息,可以看到在客戶端B的下面,已經接收到了訊息。
- 用客戶端B給客戶端A傳送訊息,也可以看到在客戶端A的下面,也已經接收到了訊息。 至於,訊息收發測試成功。至於群聊或重連等功能,就不一一演示了,還是那句話,下載demo體驗一下吧。。。
由於gif錄製體積較大,所以只能簡單演示一下訊息收發,具體下載demo體驗吧。。。
如果有需要應用層UI實現(就是聊天頁及會話頁的封裝)的話,我再分享出來吧。
github地址
寫在最後
終於寫完了,這篇文章大概寫了10天左右,有很大部分的原因是自己有拖延症,每次寫完一小段,總靜不下心來寫下去,導致一直拖到現在,以後得改改。第一次寫技術分享文章,有很多地方也許邏輯不太清晰,由於篇幅有限,也只是貼了部分程式碼,建議大家把原始碼下載下來看看。一直想寫這篇文章,以前在網上也嘗試過找過很多im方面的文章,都找不到一篇比較完善的,本文談不上完善,但包含的模組很多,希望起到一個拋磚引玉的作用,也期待著大家跟我一起發現更多的問題並完善,最後,如果這篇文章對你有用,希望給我一個star哈。。。