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开发中的最后一道高墙。缘由在于:实时音视频技术 = 音视频处理技术 + 网络传输技术 的横向技术应用集合体,而公共互联网不是为了实时通讯设计的。实时音视频技术上的实现内容主要包括:音视频的采集、编码、网络传输、解码、播放等环节。这么多项并不简单的技术应用,若是把握不当,将会在在实际开发过程当中遇到一个又一个的坑。