IM的第三方服務商國內有不少,底層協議基本上都是基於TCP的,相似有網易雲信、環信、融雲、極光IM、LeanCloud、雲通信IM(騰訊)、雲旺IM(阿里)、容聯雲、小能、美洽等等,技術也相對比較成熟,提供後臺管理和定製化的UI,拿來主義,半小時集成。css
缺點也很明顯:定製化程度過高,須要二次開發,不少東西咱們不可控,關鍵是太貴了。若是IM對於APP只是一個輔助功能,如客服系統、消息推送等,也基本夠用。html
幾乎全部互聯網IM產品都用服務器中轉方式進行消息傳輸。本身去實現也會面臨許多選擇:java
一、傳輸協議的選擇:TCP仍是UDP?
二、選擇哪一種聊天協議進行開發:MQTT、XMPP、基於 Socket 原生或 WebSocket 的私有協議?
三、傳輸數據的格式:用JSON、仍是XML、仍是谷歌推出的ProtocolBuffer?
四、咱們還有一些細節問題須要考慮,例如TCP的長鏈接如何保持,心跳機制,Qos機制,重連機制等等。另外,還有一些安全問題須要考慮。node
移動端IM的傳輸協議選型:TCP仍是UDP?
TCP:基於鏈接的可靠協議的全雙工的可靠信道,有流量控制、差錯控制等,佔用系統資源較多,傳輸效率相對低
UDP:基於無鏈接的不可靠協議,沒有足夠的控制手段,傳輸效率高,有丟包問題ios
基於UDP協議開發成本較高,容易各類丟包或亂序,通常小公司或技術不成熟或即時性要求不高的公司,多用TCP開發。
QQ-IM的私有協議:登陸等安全性操做使用TCP協議,好友之間發消息主要使用UDP協議,內網傳輸文件採用了P2P技術,另外騰訊還用了本身的私有協議,來保證傳輸的可靠性。git
首先咱們以實現方式來切入,基本上有如下四種實現方式:github
基於Socket原生:表明框架 CocoaAsyncSocket。
基於WebSocket:表明框架 SocketRocket。
基於MQTT:表明框架 MQTTKit。
基於XMPP:表明框架 XMPPFramework。web
以上四種方式均可以不使用第三方框架,直接基於OS底層Socket去實現咱們的自定義封裝。其中MQTT和XMPP爲聊天協議,是最上層的協議,而WebSocket是傳輸通信協議,它是基於Socket封裝的一個協議。而上面所說的QQ-IM的私有協議,就是基於WebSocket或者Socket原生進行封裝的一個聊天協議。
總之,iOS端要作一個真正的IM產品,通常都是基於Socket或WebSocket等,在之上加上一些私有協議來保證的。
Socket其實並非一個協議,Socket一般也稱做」套接字」,是對TCP/IP 或者UDP/IP協議封裝的一組編程接口,用於描述IP地址和端口,使用socket實現進程之間的通訊(跨網絡的)。它工做在 OSI 模型會話層(第5層),Socket是對TCP/IP等更底層協議封裝的一個抽象層,是一個調用接口(API)。網絡上的兩個程序經過一個雙向的通信鏈接實現數據的交換,這個雙向鏈路的一端稱爲一個Socket,一個Socket由一個IP地址和一個端口號惟一肯定。
先看下基於C的BSD Socket提供的接口:
//socket 建立並初始化 socket,返回該 socket 的文件描述符,若是描述符爲 -1 表示建立失敗。 int socket(int addressFamily, int type,int protocol) //關閉socket鏈接 int close(int socketFileDescriptor) //將 socket 與特定主機地址與端口號綁定,成功綁定返回0,失敗返回 -1。 int bind(int socketFileDescriptor,sockaddr *addressToBind,int addressStructLength) //接受客戶端鏈接請求並將客戶端的網絡地址信息保存到 clientAddress 中。 int accept(int socketFileDescriptor,sockaddr *clientAddress, int clientAddressStructLength) //客戶端向特定網絡地址的服務器發送鏈接請求,鏈接成功返回0,失敗返回 -1。 int connect(int socketFileDescriptor,sockaddr *serverAddress, int serverAddressLength) //使用 DNS 查找特定主機名字對應的 IP 地址。若是找不到對應的 IP 地址則返回 NULL。 hostent* gethostbyname(char *hostname) //經過 socket 發送數據,發送成功返回成功發送的字節數,不然返回 -1。 int send(int socketFileDescriptor, char *buffer, int bufferLength, int flags) //從 socket 中讀取數據,讀取成功返回成功讀取的字節數,不然返回 -1。 int receive(int socketFileDescriptor,char *buffer, int bufferLength, int flags) //經過UDP socket 發送數據到特定的網絡地址,發送成功返回成功發送的字節數,不然返回 -1。 int sendto(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *destinationAddress, int destinationAddressLength) //從UDP socket 中讀取數據,並保存發送者的網絡地址信息,讀取成功返回成功讀取的字節數,不然返回 -1 。 int recvfrom(int socketFileDescriptor,char *buffer, int bufferLength, int flags, sockaddr *fromAddress, int *fromAddressLength)
咱們用基於OS底層的原生Socket來實現一個簡單的IM。
socket擴展閱讀
服務端須要作的工做簡單的總結下:
1.服務器調用 socket(...) 建立socket;
2.綁定IP地址、端口等信息到socket上,用函數bind(); 3.服務器調用 listen(...) 設置緩衝區; 4.服務器經過 accept(...)接受客戶端請求創建鏈接; 5.服務器與客戶端創建鏈接以後,經過 send(...)/receive(...)向客 戶端發送或從客戶端接收數據; 6.服務器調用 close 關閉 socket;
服務端能夠電腦或手機等終端,也能夠用多種語言c/c++/java/js等去實現後臺,固然OC也能夠實現。這裏咱們借用node.js實現了一個服務端,來驗證socket效果。須要在Mac上安裝node解釋器,node下載,直接下載安裝便可,也能夠終端命令安裝node。
開啓服務器:
1.打開終端
2.cd到目錄 服務端(node.js) 3.node Server.js #開啓IM服務器
IM客戶端須要作以下4件事:
1.客戶端調用 socket(...) 建立socket;
2.綁定IP地址、端口等信息到socket上,用函數bind(); 3.客戶端調用 connect(...) 向服務器發起鏈接請求以創建鏈接; 4.客戶端與服務器創建鏈接以後,就能夠經過send(...)/receive(...)向客戶端發送或從客戶端接收數據; 5.客戶端調用 close 關閉 socket;
代碼實現
咱們採用CocoaAsyncSocket框架,封裝一個名爲WYKSocketManager的單例,來對socket相關方法進行調用:
爲了demo演示方便,代碼中使用的時間都較短,實際開發中根據須要設置
#import "WYKSocketManager.h" #import "GCDAsyncSocket.h" // for TCP static NSString *Khost = @"127.0.0.1"; static uint16_t Kport = 6969; static NSInteger KPingPongOutTime = 3; static NSInteger KPingPongInterval = 5; @interface WYKSocketManager()<GCDAsyncSocketDelegate> @property (nonatomic, strong) GCDAsyncSocket *gcdSocket; @property (nonatomic, assign) NSTimeInterval reConnectTime; @property (nonatomic, assign) NSTimeInterval heartBeatSecond; @property (nonatomic, strong) NSTimer *heartBeatTimer; @property (nonatomic, assign) BOOL socketOfflineByUser; //!< 主動關閉 @property (nonatomic, retain) NSTimer *connectTimer; // 計時器 @end @implementation WYKSocketManager - (void)dealloc { [self destoryHeartBeat]; } + (instancetype)share { static dispatch_once_t onceToken; static WYKSocketManager *instance = nil; dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; [instance initSocket]; }); return instance; } - (void)initSocket { self.gcdSocket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()]; } #pragma mark - 對外的一些接口 //創建鏈接 - (BOOL)connect { self.reConnectTime = 0; return [self autoConnect]; } //斷開鏈接 - (void)disConnect { self.socketOfflineByUser = YES; [self autoDisConnect]; } //發送消息 - (void)sendMsg:(NSString *)msg { NSData *data = [msg dataUsingEncoding:NSUTF8StringEncoding]; //第二個參數,請求超時時間 [self.gcdSocket writeData:data withTimeout:-1 tag:110]; } #pragma mark - GCDAsyncSocketDelegate //鏈接成功調用 - (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port { NSLog(@"鏈接成功,host:%@,port:%d",host,port); //pingPong [self checkPingPong]; //心跳寫在這... [self initHeartBeat]; } //斷開鏈接的時候調用 - (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err { NSLog(@"斷開鏈接,host:%@,port:%d",sock.localHost,sock.localPort); if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) { NSString *msg = [NSString stringWithFormat:@"斷開鏈接,host:%@,port:%d",sock.localHost,sock.localPort]; [self.delegate showMessage:msg]; } if (!self.socketOfflineByUser) { //斷線/失敗了就去重連 [self reConnect]; } } //寫的回調 - (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag { NSLog(@"寫的回調,tag:%ld",tag); //判斷是否成功發送,若是沒收到響應,則說明鏈接斷了,則想辦法重連 [self checkPingPong]; } //收到消息 - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag { NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding]; NSLog(@"收到消息:%@",msg); if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) { [self.delegate showMessage:[NSString stringWithFormat:@"收到:%@",msg]]; } //去讀取當前消息隊列中的未讀消息 這裏不調用這個方法,消息回調的代理是永遠不會被觸發的 [self pullTheMsg]; } //爲上一次設置的讀取數據代理續時 (若是設置超時爲-1,則永遠不會調用到) - (NSTimeInterval)socket:(GCDAsyncSocket *)sock shouldTimeoutReadWithTag:(long)tag elapsed:(NSTimeInterval)elapsed bytesDone:(NSUInteger)length { NSLog(@"來延時,tag:%ld,elapsed:%f,length:%ld",tag,elapsed,length); if (self.delegate && [self.delegate respondsToSelector:@selector(showMessage:)]) { NSString *msg = [NSString stringWithFormat:@"來延時,tag:%ld,elapsed:%.1f,length:%ld",tag,elapsed,length]; [self.delegate showMessage:msg]; } return KPingPongInterval; } #pragma mark- Private Methods - (BOOL)autoConnect { return [self.gcdSocket connectToHost:Khost onPort:Kport error:nil]; } - (void)autoDisConnect { [self.gcdSocket disconnect]; } //監聽最新的消息 - (void)pullTheMsg { //監聽讀數據的代理,只能監聽10秒,10秒事後調用代理方法 -1永遠監聽,不超時,可是隻收一次消息, //因此每次接受到消息還得調用一次 [self.gcdSocket readDataWithTimeout:-1 tag:110]; } //用Pingpong機制來看是否有反饋 - (void)checkPingPong { //pingpong設置爲3秒,若是3秒內沒獲得反饋就會自動斷開鏈接 [self.gcdSocket readDataWithTimeout:KPingPongOutTime tag:110]; } //重連機制 - (void)reConnect { //若是對一個已經鏈接的socket對象再次進行鏈接操做,會拋出異常(不可對已經鏈接的socket進行鏈接)程序崩潰 [self autoDisConnect]; //重連次數 控制3次 if (self.reConnectTime >= 5) { return; } __weak __typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.reConnectTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (strongSelf.delegate && [strongSelf.delegate respondsToSelector:@selector(showMessage:)]) { NSString *msg = [NSString stringWithFormat:@"斷開重連中,%f",strongSelf.reConnectTime]; [strongSelf.delegate showMessage:msg]; } strongSelf.gcdSocket = nil; [strongSelf initSocket]; [strongSelf autoConnect]; }); //重連時間增加 if (self.reConnectTime == 0) { self.reConnectTime = 1; } else { self.reConnectTime += 2; } } //初始化心跳 - (void)initHeartBeat { [self destoryHeartBeat]; // 每隔5s像服務器發送心跳包 self.connectTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(longConnectToSocket) userInfo:nil repeats:YES]; // 在longConnectToSocket方法中進行長鏈接須要向服務器發送的訊息 [self.connectTimer fire]; } // 心跳鏈接 -(void)longConnectToSocket { // 根據服務器要求發送固定格式的數據,可是通常不會是這麼簡單的指令 [self sendMsg:@"心跳鏈接"]; } //取消心跳 - (void)destoryHeartBeat { if (self.heartBeatTimer && [self.heartBeatTimer isValid]) { [self.heartBeatTimer invalidate]; self.heartBeatTimer = nil; } } @end
咱們發了一條消息,服務端成功的接收到了消息後,把該消息再發送回客戶端,繞了一圈客戶端又收到了這條消息。至此咱們用OS底層socket實現了簡單的IM。這裏僅僅是實現了Socket的鏈接並傳輸字符串,咱們要作的遠不止於此。
心跳機制是相對時間內主動向服務器發送心跳包消息,用來檢測TCP鏈接的雙方是否可用。TCP的KeepAlive機制只能保證鏈接的存在,可是並不能保證客戶端以及服務端的可用性。
擴展閱讀:爲何說基於TCP的移動端IM仍然須要心跳保活?
真正須要心跳機制的緣由其實主要是在於國內運營商的網絡地址轉換設備超時,對於家用路由器來講, 使用的是網絡地址端口轉換(NAPT), 它不只改IP, 還修改TCP和UDP協議的端口號, 這樣就能讓內網中的設備共用同一個外網IP,形成鏈接存在,但並不必定可用。
而國內的運營商通常NAT超時的時間爲5分鐘,頻繁心跳會帶來耗電和耗流量的弊端,因此一般IM心跳設置的時間間隔爲3-5分鐘,甚至10分鐘都行。微信有一種更高端的實現方式,有興趣的小夥伴能夠看看:微信的智能心跳實現方式
心跳機制是不能徹底保證消息的即時性的,業內的解決方案是輔助採用雙向的PingPong機制。
當服務端發出一個Ping,客戶端沒有在約定的時間內返回響應的ack,則認爲客戶端已經不在線,這時咱們Server端會主動斷開Socket鏈接,而且改由APNS推送的方式發送消息。
一樣的是,當客戶端去發送一個消息,由於咱們遲遲沒法收到服務端的響應ack包,則代表客戶端或者服務端已不在線,咱們也會顯示消息發送失敗,而且斷開Socket鏈接。
理論上,本身主動斷開的Socket鏈接(如退出帳號,APP退出到後臺等),不須要重連。其餘的鏈接斷開,咱們都須要進行斷線重連。 通常解決方案是嘗試重連幾回,若是仍舊沒法重連成功,那麼再也不進行重連。
在移動網絡下,丟包、網絡重連等狀況很是之多,爲了保證消息的可達,通常須要作消息回執和重發機制。
通常有三種類型:
QOS(0),最多發送一次:若是消息沒有發送過去,那麼就直接丟失。
QOS(1),至少發送一次:保證消息必定發送過去,可是發幾回不肯定。
QOS(2),精確只發送一次:它內部會有一個很複雜的發送機制,確保消息送到,並且只發送一次。
參考易信,每條消息會最多會有3次重發,超時時間爲15秒,同時在發送以前會檢測當前鏈接狀態,若是當前鏈接並無正確創建,緩存消息且定時檢查(每隔2秒檢查一次,檢查15次)。因此一條消息在最差的狀況下會有2分鐘左右的重試時間,以保證消息的可達。由於重發的存在,接受端偶爾會收到重複消息,這種狀況下就須要接收端進行去重。通用的作法是每條消息都戴上本身惟一的message id(通常是uuid)。
擴展閱讀:
IM消息送達保證機制實現
實現的思路和基於CocoaAsyncSocket框架相似,須要編寫遵照webSocket協議的服務端,感興趣的也能夠參照實現一下。
MQTT是一個聊天協議,它比webSocket更上層,屬於應用層,它的基本模式是簡單的發佈訂閱,也就是說當一條消息發出去的時候,誰訂閱了誰就會收到消息。其實它並不適合IM的場景,例如用來實現有些簡單IM場景,卻須要很大量的、複雜的處理。這個框架是c來寫的,把一些方法公開在MQTTKit類中,對外用OC來調用,這個庫有4年沒有更新了。
XMPP是較早的聊天協議(2000年發佈第一個公開版本),當時主要是用來打通 ICQ、MSN 等 PC 端的聊天軟件而設計的,技術比較成熟,它自己有不少優勢,如開放、標準、可擴展,而且客戶端和服務器端都有不少開源的實現,可是相對於移動端它也有很明顯的缺點,譬如數據負載太重、不支持二進制,在交互中有50% 以上的流量是協議自己消耗的,須要作深度的二次開發。
移動互聯網相對於有線網絡最大特色是:帶寬低,延遲高,丟包率高和穩定性差,流量費用高。因此在私有協議的序列化上通常使用二進制協議,而不是文本協議。
常見的二進制序列化庫有Protocol Buffers和MessagePack,固然你也能夠本身實現本身的二進制協議序列化和反序列的過程,好比蘑菇街的TeamTalk。可是前面兩者不管是可拓展性仍是可讀性都完爆TeamTalk(TeamTalk連Variant都不支持,一個int傳輸時固定佔用4個字節),因此大部分狀況下仍是不推薦本身去實現二進制協議的序列化和反序列化過程。
一條消息數據用Protobuf序列化後的大小是 JSON 的1/十、XML格式的1/20、是二進制序列化的1/10。同 XML 相比, Protobuf 性能優點明顯。它以高效的二進制方式存儲,比 XML 小 3 到 10 倍,快 20 到 100 倍。
同時心跳包協議對IM的電量和流量影響很大,對心跳包協議上進行了極簡設計:僅 1 Byte 。
ProtocolBuffer可能會形成 APP 的包體積增大,經過 Google 提供的腳本生成的 Model,會很是「龐大」,Model 一多,包體積也就會跟着變大。
如何測試驗證 Protobuf 的高性能?
對數據分別操做100次,1000次,10000次和100000次進行了測試,
縱座標是完成時間,單位是毫秒
Xml,Json,Hessian,Protocol Buffers序列化對比
選擇傳輸格式的時候:ProtocolBuffer > JSON > XML
ProtocolBuffer for Objective-C 運行環境配置及使用
iOS之ProtocolBuffer搭建和示例demo
基於TCP的應用層協議通常都分爲包頭和包體(如HTTP),IM協議也不例外。包頭通常用於表示每一個請求/反饋的公共部分,如包長,請求類型,返回碼等。 而包頭則填充不一樣請求/反饋對應的信息。
一個最簡單的包頭能夠定義爲:
struct PackHeader { int32_t length_; //包長度 int32_t serial_; //包序列號 int32_t command_; //包請求類型 int32_t code_; //返回碼 };
以心跳包爲例,假設當前的serial爲1,心跳包的command爲10,那麼使用MessagePack作序列化時:length=4,serial=1,command=10,code=0,每一個字段各佔一個字節,包體爲空,僅須要4個字節。
固然這是最簡單的一個例子,面對真正的業務邏輯時,包體裏面會須要塞入更多地信息,這個須要開發根據本身的業務邏輯總結公共部分,如爲了兼容加入的協議版本號,爲了負載均衡加入的模塊id等。
除了心跳機制、PingPong機制、斷線重連機制這些被用來保證鏈接的可用,要提升IM服務時的可靠性,能作的還有不少:好比在大文件傳輸的時候使用分片上傳、斷點續傳、秒傳技術、P2P技術等來保證文件的傳輸。
咱們一般還須要一些安全機制來保證咱們IM通訊安全。如:加密傳輸、防止 DNS 污染、賬號安全、第三方服務器鑑權、單點登陸等。
精簡心跳包,心跳包只在空閒時發送,動態化心跳間隔。文件上傳、下載優化等。相似微信,服務器不作聊天記錄的存儲,只在本機進行緩存,這樣能夠減小對服務端數據的請求,一方面減輕了服務器的壓力,另外一方面減小客戶端流量的消耗。
咱們進行http鏈接的時候儘可能採用上層API,相似NSUrlSession。而網絡框架儘可能使用AFNetWorking3.0 以上版本。由於這些上層網絡請求都用的是HTTP/2 ,咱們請求的時候能夠複用這些鏈接。
更多優化相關請參考這篇文章:
《iOS端移動網絡調優的8條建議》
IM 即時通信技術在多應用場景下的技術實現,以及性能調優( iOS 視角)
IM應用中的實時音視頻技術,幾乎是IM開發中的最後一道高牆。緣由在於:實時音視頻技術 = 音視頻處理技術 + 網絡傳輸技術 的橫向技術應用集合體,而公共互聯網不是爲了實時通訊設計的。實時音視頻技術上的實現內容主要包括:音視頻的採集、編碼、網絡傳輸、解碼、播放等環節。這麼多項並不簡單的技術應用,若是把握不當,將會在在實際開發過程當中遇到一個又一個的坑。