Android 開源的IM SDK,基於Netty+TCP+Protobuf+Okhttp設計思路實現的一款可定製化的開源庫

一、前言

相信現在很多App都會被要求有IM功能,社交成爲了必不可少的一項功能,IM的價值和重要性也就不言自明,但從技術上,IM對沒有經驗的開發者來說還是存在很多坑點和難點的,而接入第三方又存在成本、受限於他人等問題,所以本文旨意在打造一個通用的可配置化的IM SDK,文筆有限,如有不妥之處還請批評指正,希望對你有用。轉載請註明出處https://www.jianshu.com/p/5b01f4d6e4f4

先上效果圖!gifhome_960x544_17s (6).gif

這裏直接模擬兩個用戶聊天,還可以模擬上線、下線狀態,開發者可以直接移步github, fork源碼查看Github地址

Netty

什麼是Netty?

Netty 是一個利用 Java 的高級網絡的能力,隱藏其背後的複雜性而提供一個易於使用的 API 的客戶端/服務器框架。
Netty 是一個廣泛使用的 Java 網絡編程框架(Netty 在 2011 年獲得了Duke’s Choice Award,見https://www.java.net/dukeschoice/2011)。它活躍和成長於用戶社區,像大型公司 Facebook 和 Instagram 以及流行 開源項目如 Infinispan, HornetQ, Vert.x, Apache Cassandra 和 Elasticsearch 等,都利用其強大的對於網絡抽象的核心代碼。

以上是摘自《Essential Netty In Action》這本書

爲什麼選擇Netty?
Netty是業界最流行的NIO框架之一,它的健壯性、功能、性能、可定製性和可擴展性在同類框架中都是首屈一指的,它已經得到成百上千的商用項目驗證,例如Hadoop的RPC框架avro使用Netty作爲底層通信框架。很多其它業界主流的RPC框架,也使用Netty來構建高性能的異步通信能力。
通過對Netty的分析,我們將它的優點總結如下:

  • API使用簡單,開發門檻低;
  • 功能強大,預置了多種編解碼功能,支持多種主流協議;
  • 定製能力強,可以通過ChannelHandler對通信框架進行靈活的擴展;
  • 性能高,通過與其它業界主流的NIO框架對比,Netty的綜合性能最優;
  • 成熟、穩定,Netty修復了已經發現的所有JDK NIO BUG,業務開發人員不需要再爲NIO的BUG而煩惱;
  • 社區活躍,版本迭代週期短,發現的BUG可以被及時修復,同時,更多的新功能會被加入;
  • 經歷了大規模的商業應用考驗,質量已經得到驗證。在互聯網、大數據、網絡遊戲、企業應用、電信軟件等衆多行業得到成功商用,證明了它可以完全滿足不同行業的商業應用。

正是因爲這些優點,Netty逐漸成爲Java NIO編程的首選框架。
以上是摘自[《Netty 權威指南》—— 選擇Netty的理由
](http://ifeve.com/netty-2-6/)

Protobuf

我們先來看看官方文檔給出的定義和描述:

protocol buffers 是一種語言無關、平臺無關、可擴展的序列化結構數據的方法,它可用於(數據)通信協議、數據存儲等。

Protocol Buffers 是一種靈活,高效,自動化機制的結構數據序列化方法-可類比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更爲簡單。

你可以定義數據的結構,然後使用特殊生成的源代碼輕鬆的在各種數據流中使用各種語言進行編寫和讀取結構數據。你甚至可以更新數據結構,而不破壞由舊數據結構編譯的已部署程序。

簡單來講, ProtoBuf 是結構數據序列化[1] 方法,可簡單類比於 XML[2],其具有以下特點:

  • 語言無關、平臺無關。即 ProtoBuf 支持 Java、C++、Python 等多種語言,支持多個平臺
  • 高效。即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更爲簡單
  • 擴展性、兼容性好。你可以更新數據結構,而不影響和破壞原有的舊程序
    所以採用protobuf傳輸是不錯的選擇。本文的protobuf實體設計:
message Pack{ //傳輸的包
enum PackType{
    MSG=0;
    REPLY=1;
    HEART=2;
    SHAKEHANDS=3;

}
    PackType packType=1;//包類型
    oneof body{
      Msg msg=2;//聊天消息
      Reply reply=3;//通用消息回執
      Heart heart=4;//心跳包
      ShakeHands shakeHands=5;//握手認證
    }
}

關於prototbuf裏 oneof 說明
如果你的消息中有很多可選字段, 並且同時至多一個字段會被設置, 你可以加強這個行爲,使用oneof特性節省內存.Oneof字段就像可選字段, 除了它們會共享內存, 至多一個字段會被設置。 設置其中一個字段會清除其它字段。 你可以使用case()或者WhichOneof() 方法檢查哪個oneof字段被設置, 看你使用什麼語言了。本庫的protobuf的版本爲3.11.4,是在windows用protobuf工具生成然後再導入項目的。

框架設計

本庫使用OKhttp的設計模式,是因爲IM特性和OKhttp具有一定的共性, 所以本庫借鑑OKhttp設計思想,來讓我們看一下構造一個IMClient可以有多精簡。

 private IMClientDemo(DefaultMessageReceiveHandler.onMessageArriveListener onMessageArriveListener){
        imClient=new IMClient.Builder()
                .setCodec(new DefaultCodec()) //默認的編解碼,開發者可以使用自己的protobuf編解碼
                .setShakeHands(getDefaultHands(),new DefaultShakeHandsHandler()) //設置握手認證,可選
                .setHeartBeatMsg(getDefaultHeart()) //設置心跳,可選
                .setMessageRespHandler(new DefaultMessageRespHandler()) //消息響應,開發者可自行定製實現MessageRespHandler接口即可
                .setMessageReceiveHandler(new DefaultMessageReceiveHandler(onMessageArriveListener)) //客戶端消息接收器
                .setEventListener(new DefaultEventListener("user id1")) //事件監聽,可選
                .setAddress(new Address("192.168.69.32",8765,Address.Type.SOCKS))
                .setAddress(new Address("www.baidu.com",8765,Address.Type.HTTP))
                .build();
    }

這裏使用了構建者模式,可以自由裝配,你可以添加自定義攔截器、自定義channelHandler,設置連接超時、發送超時、重發次數等…

   Dispatcher dispatcher;
     final List<Interceptor> interceptors = new ArrayList<>();
     int connectTimeout;//連接超時
     int sendTimeout;//發送超時,規定時間內需服務端響應
     int resendCount;//消息發送失敗,重發次數
     boolean connectionRetryEnabled;//是否連接失敗、連接重試
     int heartIntervalForeground;//前臺心跳間隔
     int heartIntervalBackground;
     boolean isBackground;
     EventListener.Factory eventListenerFactory;
     ConnectionPool connectionPool;
     Cache cache;
     Authenticator authenticator;
     List<Address> addressList;
     @Nullable Codec codec;
     LinkedHashMap<String , ChannelHandler> customChannelHandlerLinkedHashMap;
     com.google.protobuf.GeneratedMessageV3 loginAuthMsg;
     com.google.protobuf.GeneratedMessageV3 heartBeatMsg;
     ShakeHandsHandler shakeHandsHandler;
     HeartbeatRespHandler heartbeatRespHandler;
     MessageRespHandler messageRespHandler;
     MessageReceiveHandler messageReceiveHandler;

核心實現在幾個內置攔截器中

    Response getResponseWithInterceptorChain(SubsequentCallback callback) throws IOException, InterruptedException, AuthException, SendTimeoutException {
        // Build a full stack of interceptors.
        List<Interceptor> interceptors = new ArrayList<>();
        if (client.interceptors()!=null&&client.interceptors().size()>0){
            interceptors.addAll(client.interceptors());
        }
        interceptors.add(retryAndFollowUpInterceptor);
        interceptors.add(new BridgeInterceptor(client));
       // interceptors.add(new CacheInterceptor());
        interceptors.add(new ConnectInterceptor(client));
        interceptors.add(new CallServerInterceptor(callback));


        Interceptor.Chain chain = new RealInterceptorChain(
                interceptors, null, null, null, 0, originalRequest,this, eventListener,client.connectTimeout(),
                client.sendTimeout());
        return chain.proceed(originalRequest);
    }

是不是似曾相識的感覺,這裏攔截器功能和okhttp雷同,retryAndFollowUpInterceptor進行連接重試、發送重試、地址切換,BridgeInterceptor主要進行數據的裝配,ConnectInterceptor是真正的連接的地方,CallServerInterceptor進行數據的寫讀,這裏的讀是服務端的消息回執,完成這一套攔截器那麼我們整體流程就有了,那我們怎麼進行一個消息的發送呢?

   IMClientDemo.getInstance().sendMsg(createRequest(appMessage), new UICallback.OnResultListener<PackProtobuf.Pack>() {
                    @Override
                    public void onFailure(IOException e) {
                        appMessage.msgStatus = MSG_STATUS_FAILED;
                        messageAdapter1.onItemChange(appMessage); //更新一條消息狀態
                    }
                    @Override
                    public void onResponse(PackProtobuf.Pack pack) {
                            appMessage.msgStatus=pack.getReply().getStatusReport();  //收到服務端響應,即代表消息發送成功,更新UI
                            messageAdapter1.onItemChange(appMessage);
                    }
                });

該onResponse會收到服務端的該條消息的回執,注意這裏不是這條消息的回執將不會回調,那麼我們是如何做到這一點呢,這裏就要說到request,每一個消息發送我們都把它當做一個request來處理,以下是一個request創建的樣例:

  private Request createRequest(AppMessage appMessage){
        Request request=new Request.Builder().
                setRequestTag(appMessage.getHead().getMsgId()).
                setBody(getMsgPack(appMessage.buildProto())).
                build();
        return request;
    }

這裏創建一個requestTag,如果每一個你發送的消息希望等到消息回執,那麼你只要帶上requestTag並且在構造IMCient時實現MessageRespHandler接口即可image.png
request還可以做一些別的操作,例如設置request發送失敗不重試(默認失敗重試),設置request無需服務端響應(默認需要響應),這種設置就很符合這種場景:發送回執消息給服務端,因爲回執消息是無需服務端來響應的

重試機制

爲了保證IM高可用,一定要有重試機制,重試機制包括連接失敗重試、切換地址、發送重試、重試的次數、超時時間等。
在該庫中連接重試主要實現在retryAndFollowUpInterceptor類中,我這裏截取了部分

       try {
                response = ((RealInterceptorChain) chain).proceed(request, streamAllocation, null, null);
                releaseConnection = false;
            }  catch (IOException e) {
                if (request instanceof ConnectRequest){ //連接請求重試
                    if (!connectRecover(e,request, ++connect_retry)) {
                        realChain.eventListener().connectFailed(streamAllocation.currentInetSocketAddress(),e);
                        throw e;
                    }
                       System.out.println("連接重試 " + streamAllocation.currentInetSocketAddress().toString());
                       releaseConnection=false;
                       continue;
                }
                if (!sendRecover(e,  request,++resendCount)){
                    realChain.eventListener().sendMsgFailed(realChain.call());
                    throw e;
                }
                      //發送請求重試
                       System.out.println("發送重試");
                       releaseConnection=false;
                       continue;

            } catch (InterruptedException e) {

如果連接拋了異常,先判斷能不能恢復,如果能恢復且連接次數沒有超出限制,即繼續重試,如果連接次數到一定數量我們可以切換下一個可用地址重試,發送失敗也是這樣處理的,如果滿足恢復條件,即發送重試,連接超時則是在netty上配置,發送超時定義爲用戶發送消息且沒在規定時間內服務端沒有給出回執,我們則認定此條消息發送失敗的也要進行重試。

消息可靠性和消息回執

如果要保證消息的可靠完整性,那麼一定就要有確認機制,有人可能會有疑問,TCP/IP不就是可靠性協議嘛,有消息重傳,且保證包有序。但是協議的可靠不一定代表應用層的可靠,例如如下場景:A客戶端給B客戶端發送消息,消息先到達服務端,而此時服務端出現異常,此時TCP/IP協議肯定是認爲此條消息已經送達到服務端了,只是服務端處理出現了異常,客戶端A就以爲發送成功了,但是A客戶端此條消息真實情況應該是發送失敗的,那麼這樣就會造成消息丟失。如果我們使用了回執機制,還是同樣的場景,當A客戶端發送消息給服務端,此時服務端出現異常,當A客戶端一段時間內沒收到服務端的消息回執即認定爲發送失敗,A客戶端會重發此條消息,同樣的道理當服務端發給客戶端的消息沒有得到響應時,也會重發消息,這樣一套確定機制即可保證消息的完整,舉一反三,既然我們可以發送確認消息回執,那麼我們也可以做一些別的類型的消息回執,例如:消息已讀、消息撤回、消息已送達等(這裏已送達的概念和已發送是不一樣的,已送達代表消息已經到了接收方那,而已發送是代表消息成功發送到服務端)。本文在消息回執設計在protobuf裏的是這樣的

message Reply{ //消息回執
  int32 replyType=1;//回覆類型
  string msgId=2;//對應的消息ID
  string userId=3;//用戶ID
  int32 statusReport=4;//狀態
}

握手連接認證

爲了防止一些不明的連接,服務端應該對客戶端發起的連接進行身份認證,當客戶端和服務端建立連接後,應該立刻發送一個握手連接認證,服務端對握手連接進行認證,如果身份認證成功則將該連接放入內存中,並且發送回執給客戶端,否立發送失敗回執給客戶端並且關閉連接。以下是本庫的默認握手實現,開發者可自行替換自己的實現,實現接口即可。

/**
 * 默認握手實現
 */
public class DefaultShakeHandsHandler implements ShakeHandsHandler<PackProtobuf.Pack> {

   public static final int SHAKE_HANDS_REPLY_TYPE=0x12;
    public static final int SHAKE_HANDS_STATUS_SUCCESS=1;
    public static final int SHAKE_HANDS_STATUS_FAILED=0;

    @Override
    public boolean isShakeHands(Object msg) {
        PackProtobuf.Pack pack= (PackProtobuf.Pack) msg;
        return pack.getPackType()==PackProtobuf.Pack.PackType.REPLY//包類型是回執包且回執類型是握手回執
                &&pack.getReply().getReplyType()==SHAKE_HANDS_REPLY_TYPE;
    }

    @Override
    public boolean isShakeHandsOk(PackProtobuf.Pack pack) {
        if (pack.getReply().getStatusReport()== SHAKE_HANDS_STATUS_SUCCESS ){
            return true;
        }else {
            return false;
        }
    }
}

心跳機制

心跳包

**作用:**其實主要是爲了防止NAT超時。其次是探測連接是否斷開。

心跳包和輪詢的區別

心跳包和輪詢看起來類似, 都是客戶端主動聯繫服務器, 但是區別很大:
(1)輪詢是爲了獲取數據, 而心跳是爲了保活TCP連接。
(2)輪詢得越頻繁, 獲取數據就越及時, 心跳的頻繁與否和數據是否及時沒有直接關係
(3)輪詢比心跳能耗更高, 因爲一次輪詢需要經過TCP三次握手, 四次揮手, 單次心跳不需要建立和拆除TCP連接。

NAT耗時

國內移動無線網絡運營商在鏈路上一段時間內沒有數據通訊後, 會淘汰NAT表中的對應項, 造成鏈路中斷。

image

心跳保活

心跳一般是指某端(絕大多數情況下是客戶端)每隔一定時間向對端發送自定義指令,以判斷雙方是否存活,因其按照一定間隔發送,類似於心跳,故被稱爲心跳指令
以上採摘於https://www.jianshu.com/p/3cdd52626a1b
以下是本庫的心跳實現:

           // 3次心跳時間內沒得到服務端響應,即可代表連接已斷開
            channel.pipeline().addFirst(IdleStateHandler.class.getSimpleName(), new IdleStateHandler(
                    heartbeatInterval * 3, heartbeatInterval, 0, TimeUnit.MILLISECONDS));

            // 重新添加HeartbeatHandler
            if (channel.pipeline().get(HeartbeatChannelHandler.class.getSimpleName()) != null) {
                channel.pipeline().remove(HeartbeatChannelHandler.class.getSimpleName());
            }
            if (channel.pipeline().get(IdleStateHandler.class.getSimpleName()) != null) {
                channel.pipeline().addLast(HeartbeatChannelHandler.class.getSimpleName(),
                        new HeartbeatChannelHandler(connectionPool,heartBeatMsg,connectionBrokenListener));
            }
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        super.userEventTriggered(ctx, evt);
        if (heartbeatMsg==null){
            return;
        }
        if (evt instanceof IdleStateEvent) {
            IdleState state = ((IdleStateEvent) evt).state();
            switch (state) {
                case READER_IDLE: {
                    connectionBrokenListener.connectionBroken();
                    break;
                }
                case WRITER_IDLE: {
                    // 規定時間內沒向服務端發送心跳包,則馬上發送一個心跳包
                    if (heartbeatTask == null) {
                        heartbeatTask = new HeartbeatTask(ctx);
                    }
                    connectionPool.execWorkTask(heartbeatTask);
                    break;
                }
            }
        }
    }

離線消息

離線消息這個算服務端的內容,服務端根據判斷消息接收方是否在線,如果在線則直接發送,如果不在線,緩存起來或者入庫都可以,等接受方上線且認證了立即把離線消息發送給客戶端。客戶端要考慮的是消息緩存的問題,例如在網絡不佳或者突然斷網的情況下,發送的消息失敗是否需要在連接狀態變好的時候重新發送,這時就應該考慮消息入庫,本SDK沒有具體實現這個部分,但是細想該SDK可以動態添加攔截器,是否可以添加一個消息緩存的攔截器,每次發送的消息經過緩存攔截器先進行入庫,得到響應後經過攔截器再更新消息狀態。服務端demo有簡易的離線消息處理,這裏也貼下代碼吧

     PackProtobuf.Msg message=pack.getMsg();
                System.out.println("收到發送方客戶端發送過來的消息:"+message.toString());
                ChannelContainer.getInstance().getChannelByUserId(message.getHead().getFromId())//回給發送端消息回執已經發送
                        .writeAndFlush(createMsgReply(message.getHead().getFromId(),message.getHead().getMsgId(),MSG_REPLY_TYPE, NettyServerDemo.MSG_STATUS_SEND));
                if (ChannelContainer.getInstance().isOnline(message.getHead().getToId())){ //如果接受發在線
                    ChannelContainer.getInstance().getChannelByUserId(message.getHead().getToId()) //轉發給接受端
                            .writeAndFlush(pack);
                }else { //如果對方離線,緩存起來,等用戶上線立馬發送
                    putOffLienMessage(message.getHead().getToId(),pack);
                }

###三、寫在最後
本人文筆着實不好,寫文章真的很讓人頭疼,hhhh!還不如寫代碼舒心,在此還要感謝FreddyChen提供的netty精簡庫與部分思路。
但本着開源精神,竟然庫寫了就應該有一個介紹文章吧,所以就有了這篇文章。歡迎大家指出問題、提交issue或者有bug私聊我都可以哈,如果您覺得此篇文章對你有幫助,歡迎給個star哈!Github地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章