Socket学习总结系列(一) -- IM & Socket
前言
Socket通讯在iOS中也是很常见,自己最近也一直在学习Telegram这个开源项目,Telegram就是在Socket的基础上做的即时通讯,这个相信了解这个开源项目的也都知道,希望自己能慢慢的了解一下它的这个MtProtoKit开源协议,即时通讯这一块的东西我以前写过一篇《iOS 即时通讯 + 仿微信聊天框架 + 源码》,从点击量看的出来真的这一块的东西我们的需求量还是很大,《iOS 即时通讯 + 仿微信聊天框架 + 源码》这篇文章由于自己去年也是能力有限,现在我自己去看也会觉得很多地方不怎么尽如人意,接下来Socket可以说这一个系列的自己准备认认真真的写下去,不能急于求成,希望能认真扎实的把这系列的东西总结一下,至少让自己觉得总结的全面一点。
什么是Socket
我们从聊天的一些常用的协议开始慢慢的理解这个Socket,不想在这了直接抛出Socket的概念,在脑海中有这个问题就行!后面我们会慢慢的一步一步找到答案!在这里先给出两个链接:
《iOS即时通讯,从入门到“放弃”?》这篇文章细致的说了iOS即时通讯这一块作者自己理解和总结,能很好的帮助我们理解即时通讯,只是篇幅有点长,要看完全得花点时间。
《关于iOS socket都在这里了》这篇文章我看过之后也觉得很不错,上面说的文章要是能帮我们理解即时通讯,那这篇文章就能帮我们消化一下Socket。
上面这两篇文章我相信会对我们帮助会很大,所以是强烈推荐!结合这两篇内容,我们开始慢慢的总结Socket,在文章中所有的练习Demo以及详细的一些集成过程Demo都会给大家。过了一年,再问一下自己到底该怎样实现即时通讯?
(1)第三方IM服务
第三方能做即时通讯的是在是太多了,环信,融云,网易云信等等....说说我自己的理解:
1、他们的技术的确没问题,是OK的。要是你公司整个开发团队技术实力一般(普通公司的整个团队技术其实实力真的一般,包括我自己待过的一些公司),还有以前你公司也没有自己做过即时通讯并且还有一点就是想速成,那我就建议使用第三方!这一点说说我自己的亲身经历感觉会更真实一点,我待的第一家公司,项目有即时通讯的需求,我们是利用Socket来做,做到最后还是没有做出一个让人满意的即时通讯,消息的丢失、链接状态的不稳定,刚开始的时候也根本还没怎么考虑过信息安全这些问题,第二家有了第一次的前车之鉴再结合整个团队的实力水平,选择利用环信做,整体感觉比第一次好了许多,等到现在接触到Telegram,才觉得一般的公司还是推荐使用第三方,那样至少能保证你这个功能是没问题的。
2、第三方的弊端,收费!定制化程度不是完全掌握在你的手里,收费这一点,像网易这些在开始就明确了价格怎样供你选择,像环信这种,会在某一个适当的时机,当你用户积累到一定量的时候开始收费。这个成本在开发的时候是必须需要考虑的。
3、没办法真正的提高你对即时通讯的理解以及水平的提升。
(2) 有能力自己做
有能力自己做真的肯定是最好,这个时候就需要你去好好认证的学习即时通讯所需要的方方面面,这个过程你的收获肯定会很大,但困难也肯定会有,需要自己权衡,自己做,我们该怎样开始?下面就重点总结一下我们自己开始做即时通讯的时候我们应该掌握的东西。
即时通讯 - 选择
这个时候你需要选择我们使用的传输协议: TCP 还是 UDP
当然,你在选择之前,肯定要知道什么是TCP?什么是UDP?(这里就说一个题外话,在刚开始接触iOS的时候,经常会看到有些人吐槽,我一个做iOS的,面试的时候常遇到有人问什么是TCP什么是UDP,我需要了解这些干嘛?有用吗?有必要吗?不知道这样吐槽的你工作几年之后会不会觉得那时的你很青涩?这些我们需要掌握的必要性我就不在多提了,我也相信看这篇文章朋友也不会有这样的想法!)
这个选择问题,我给到一篇即时通讯网上比较好的文章的结论:文章链接《移动端IM/推送系统的协议选型:UDP还是TCP?》希望需要的朋友认真看看。下面是文章最后给的结论:
认真看看文章,传输协议我相信你也可能做一个正确的选择了,那接下来你还得考虑聊天协议用哪个,所以这一块的主题就是 -- 选择
聊天协议这里总结,比较一些它们之间的优缺点:
上面的总结能帮助我们区分这些协议,清楚了这些,我们自己在做即时通讯的时候。最好的选择还是利用Socket来实现,下面就总结一些自己学习之后对Socket的理解,要是有什么不对的地方,大家可以指出来。
关于Socket的理解
Socket(中文名:套接字)是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元,Socket本身并不是协议,Socket本质是编程接口(API),是对TCP/IP的封装。通过Socket,我们能更好的使用TCP/IP 协议,而不是说只有通过Socket我们才能使用TCP/IP协议。要是没有Socket我们就得直面传输层的TCP/IP协议,这个工作量就会更大,难度也会更大!
建立Socket连接至少需要一对套接字,其中一个运行于客户端称为ClientSocket ,另一个运行于服务器端称为ServerSocket 。
下面是百度找的Socket使用TCP协议建立连接的一个流程图:
其实这整个步骤就包括了它的连接,客户端发送消息以及读取消息,服务端接收消息和给客户端发送消息以及到最后一个断开连接的请求等等的过程,我们先看看整个连接的过程,也有人总结过大概是下面这样的几个步骤:(前面那些初始化过程就不提,这个自己注意就行)
1、服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态(也就是上面的阻塞直到客户端连接),实时监控网络状态,等待客户端的连接请求。注意上面的bind() Lister() accept() 这些都是服务端需要做的操作,这个方法我们在下一篇讲CocoaAsyncSocket源码的时候会看到,在这里留个印象,有助于后面的理解。
2、客户端请求:客户端初始化Socket提出连接请求,要连接的目标是服务器端的Socket。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。(这个过程在你客户端初始化Socket在连接时候能在加深理解这一步)
3、连接确认:当服务器端Socket监听到或者说接收到客户端Socket的连接请求时,就响应客户端Socket的请求,建立一个新的线程,把服务器端Socket的描述发给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。
通过上面的过程,你的Socket就和服务端Socket建立了连接!
再补充一下:我们在传输数据时,可以使用(传输层)TCP/IP协议,但是那样的话,如果没有应用层,便无法识别数据内容,如果想要使传输的数据有意义,则必须使用到应用层协议,应用层协议有很多,比如我们常说的HTTP(Http是应用层的协议,它实际上也建立在TCP协议之上)。Web使用HTTP协议作应用层协议,以封装HTTP文本信息,然后使用TCP/IP做传输层协议将它发到网络上。
在Http和Socket之间,百度上面这个例子也是被很多的博客所用:Http是轿车,提供了封装或者显示数据的具体形式;Socket是发动机,提供了网络通信的能力。
上面的这些你要理解前面说过的TCP,UDP,网络层级这些东西,理解这个Socket就会容易很多!所以还是建议理解TCP,UDP这些协议,像TCP连接时候的三次握手,断开时候的四次挥手。它和UDP之间的区别,还有Http的特点这些还是了解一下的好,这些我自己以前也是没有总结过,等写完这些再好好总结一些这些网络层次以及这些协议。
上面说了Socket的连接之后,随之我们就再理解一下和它永远都在一起的几个问题: 心跳机制 PingPong机制 断线重连
一: 心跳机制
心跳一般是指某端(绝大多数情况下是客户端)每隔一定时间向服务端发送自定义指令,以判断双方是否存活,因其按照一定间隔发送,类似于心跳一样,所以大家也就都叫它心跳机制。关于心跳的我们在这里介绍完之后在后面的Demo中我会把源码给大家,具体的可以结合源码一起看看。
Socket选择TCP传输协议建立了连接,这个TCP协议有一个KeepAlive机制,下面文章也是很明确的指出了为什么不能用TCP协议的KeepAlive机制来做心跳的原因, 总结的观点是: TCP KeepAlive 是用于检测连接的死活,而不是用来检测连接是否可用!而我们的心跳就是为了检测连接死活的同时还要检测连接是否可用!
再说说你用心跳保持长连接,那你的心跳方案怎样设计呢?在参考文章的最下面文章给出了这样两个观点,先看图:
参考文章 《为什么说基于TCP的移动端IM仍然需要心跳保活? 》
总结一下上面的两个减少定时心跳的方法:
1、 考虑定时的时间间隔问题,这个问题其实有时候在面试的时候也会有人问,怎样去确定心跳计时的时间间隔或者减少心跳等,上面给的第一个就是在计时的时间间隔上考虑。这个具体的时间设置多少,我们下面会仔细说说。
2、从最后收到消息开始进行周期计时而不是设计固定的时间间隔,这个我自己的理解,收到消息的时候你做标记计时,要是在某某时间内又有消息收到,就不进行心跳,要是在某某时间内没有在收到消息就进行心跳。当然在你计时时间内你又收到了消息,这个计时肯定也要刷新,这个涉及到具体实践细节。
服务端的和我们上面说的道理是一样的!
衍生的问题: 这个时间间隔到底你该怎样去设定? 在我们文章开头给的推荐文章一种就有比较详细的解释这个问题:
具体的原因说到这个 -- NAT 超时,你要有兴趣可以点击去百度具体了解一下什么是NAT超时,这里我们就不在占据篇幅写这个,建议还是在我们最前面推荐的文章去了解一下这个NAT超时,说说我们的结果:而国内的运营商一般NAT超时的时间为5分钟,所以通常我们心跳设置的时间间隔为3-5分钟。
二: PingPong机制
这个的出现是为了在我们设置的这个心跳间隔之内出现了连接问题,就像参考文章说的那样我们在地铁电梯这些场所当中的时候,那它具体是什么?
当服务端发出一个Ping
,客户端没有在约定的时间内返回响应的ack
,则认为客户端已经不在线,这时我们Server
端会主动断开Scoket
连接,并且改由APNS
推送的方式发送消息。
Scoket
连接。 我们自己主动去断开的Scoket
连接(退出登录,App退出到后台等等)是不需要重连。其他的连接断开,我们都需要进行断线重连,一般解决方案是尝试重连几次,如果仍旧无法重连成功,那么不再进行重连。这个简单的了解一下,知道就行。
上面关于Socket的理论性的东西我们也就说的差不多了,下面的重点是通过代码消化一下上面说的理论知识。
Socket主要的API
接下来就看看在iOS中Socket的源码里面都有些什么东西,可以先看一下,创建一个文件,导入 #import <sys/socket.h> 可以进去看看Socket里面的东西。它最主要是下面一组接口,给我们提供了它的方法,我们在下面对它进行一个仔细的注释分析:
按照我们前面说的整个的一个过程,我们归纳一下客户端利用Socket发送消息的整个过程:
1、初始化Socket 使用方法 int socket (int , int ,int )
2、连接Socket 使用方法 int connect(int, const struct sockaddr *, socklen_t)
3、发送、接收数据 使用方法 ssize_t send(int, const void *, size_t, int)/ssize_t recv(int, void *, size_t, int)
ssize_t sendto(int, const void *, size_t,int, const struct sockaddr *, socklen_t)和
ssize_t recvfrom(int, void *, size_t, int, struct sockaddr * __restrict, socklen_t * __restrict)
上面发送方法的是有区别的,在下面的注释中我们添加说明。
4、关闭Socket 使用方法 close() 这个方法在后面我在说,在Socket源码是看不到这个方法的。
下面是重要的一些Socket方法的方法的注释:
/*为了保证阅读的顺序和源码的顺序对应,方便大家查看,就不调整方法顺序,比如初始化的方法位置没有调整。解释的也是主要的,没有全部都解释 __BEGIN_DECLS 这个方法是在服务端用到,表示接受客户端请求,并讲客户端的网络地址保存在sockaddr类型指针__restrict,后面的__restrict是地址的长度 int accept(int, struct sockaddr * __restrict, socklen_t * __restrict) __DARWIN_ALIAS_C(accept); 将Socket与指定的主机地址与端口号绑定,绑定成功返回0.失败返回-1,这个方法你可以在CocoaAsyncSocket源码中看到 int bind(int, const struct sockaddr *, socklen_t) __DARWIN_ALIAS(bind); 客户端Socket的连接方法,成功返回0,失败返回-1,第一个参数是你初始化一个Socket获取到的文件描述符,初始化Socket返回的文件描述符是int类型,这个你在下面可以看到。 第二个参数是一个指向要连接Socket的sockaddr结构体的指针, 第三个参数代表sockaddr结构体的字节长度 参考:https://baike.baidu.com/item/connect%28%29/10081861?fr=aladdin int connect(int, const struct sockaddr *, socklen_t) __DARWIN_ALIAS_C(connect); 获取Socket的地址 参考:https://baike.baidu.com/item/getpeername%28%29 int getpeername(int, struct sockaddr * __restrict, socklen_t * __restrict) __DARWIN_ALIAS(getpeername); 获取Socket的名称 参考:https://baike.baidu.com/item/getsockname%28%29 int getsockname(int, struct sockaddr * __restrict, socklen_t * __restrict) __DARWIN_ALIAS(getsockname); https://baike.baidu.com/item/getsockopt%28%29 int getsockopt(int, int, int, void * __restrict, socklen_t * __restrict); 用于服务端监听客户端,传的两个参数一个是初始化Socket获取到的文件描述符, 第二个是等待连接队列的最大长度 如无错误发生,listen()返回0。否则的话,返回-1 方法带参数这样 int listen( int sockfd, int backlog) ,这个方法在CocoaAsyncSocket源码中也用做判断 参考:https://baike.baidu.com/item/listen%28%29 int listen(int, int) __DARWIN_ALIAS(listen); 接收消息方法,详细https://baike.baidu.com/item/recv%28%29 ssize_t recv(int, void *, size_t, int) __DARWIN_ALIAS_C(recv); 从UDP Socket中读取数据 ssize_t recvfrom(int, void *, size_t, int, struct sockaddr * __restrict,socklen_t * __restrict) __DARWIN_ALIAS_C(recvfrom); ssize_t recvmsg(int, struct msghdr *, int) __DARWIN_ALIAS_C(recvmsg); 发送消息方法 参考: https://baike.baidu.com/item/send%28%29#3 ssize_t send(int, const void *, size_t, int) __DARWIN_ALIAS_C(send); send,sendto以及sendmsg系统调用用于发送消息到另一个套接字。send函数在套接字处于连接状态时方可使用。而sendto和sendmsg在任何时候都可使用 ssize_t sendmsg(int, const struct msghdr *, int) __DARWIN_ALIAS_C(sendmsg); sendto()适用于发送未建立连接的UDP数据包 ssize_t sendto(int, const void *, size_t,int, const struct sockaddr *, socklen_t) __DARWIN_ALIAS_C(sendto); int setsockopt(int, int, int, const void *, socklen_t); shutdown()是指禁止在一个Socket上进行数据的接收与发送。 int shutdown(int, int); int sockatmark(int) __OSX_AVAILABLE_STARTING(__MAC_10_5, __IPHONE_2_0); 重点理解一下Socket的初始化,完整的参数方法是这样: int socket(int domain, int type, int protocol) domain 参数表示制定使用何种的地址类型 比如: PF_INET, AF_INET: Ipv4网络协议 PF_INET6, AF_INET6: Ipv6网络协议 type 参数的作用是设置通信的协议类型 SOCK_STREAM: 提供面向连接的稳定数据传输,即TCP协议。 OOB: 在所有数据传送前必须使用connect()来建立连接状态。 SOCK_DGRAM: 使用不连续不可靠的数据包连接。 SOCK_SEQPACKET: 提供连续可靠的数据包连接。 SOCK_RAW: 提供原始网络协议存取。 SOCK_RDM: 提供可靠的数据包连接。 SOCK_PACKET: 与网络驱动程序直接通信。 参数protocol用来指定socket所使用的传输协议编号。这一参数通常不具体设置,一般设置为0即可。 参考:https://baike.baidu.com/item/socket%28%29 上面的解释要是结合CocoaAsyncSocket的源码再去理解,会理解的更透彻 int socket(int, int, int); int socketpair(int, int, int, int *) __DARWIN_ALIAS(socketpair); #if !defined(_POSIX_C_SOURCE) int sendfile(int, int, off_t, off_t *, struct sf_hdtr *, int); #endif !_POSIX_C_SOURCE #if !defined(_POSIX_C_SOURCE) || defined(_DARWIN_C_SOURCE) void pfctlinput(int, struct sockaddr *); int connectx(int, const sa_endpoints_t *, sae_associd_t, unsigned int, const struct iovec *, unsigned int, size_t *, sae_connid_t *); int disconnectx(int, sae_associd_t, sae_connid_t); #endif (!_POSIX_C_SOURCE || _DARWIN_C_SOURCE) __END_DECLS*/
原生Socket消息发送接收Demo
这里就没有再做GIF格式的动图,怕这个消息发送接收做的图太大!效果图还是给出来,你可以在这里看到CONNECTED连接成功,也可以看到收到的消息Data,最后的你断开连接的时候服务端的CLOSE我就不上图:这个Demo由于比较的小,我把主要的代码给出来,Demo源码会在这个系列文章和后面的demo一起传上来:
这个是SocketManager这个单例类的.m源码,上面全都有注释,当然你急需要源码。可以加我QQ找我,我发给你!这个Demo后面整理上传!
#import "SocketManager.h" #import <sys/socket.h> #import <sys/types.h> #import <netinet/in.h> #import <arpa/inet.h> @interface SocketManager() @property (nonatomic,assign)int clientScoket; // Socket @property (nonatomic,assign)int connetSocketResult;// Socket连接结果 @end @implementation SocketManager /** NOTE: 里面涉及到一些Socket的创建的具体的方法你要是不理解可以暂时放下 等读完CocoaAsyncSocket的源码的具体注释就可以理解 */ +(instancetype)shareInstance{ static SocketManager * manager = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ manager = [[SocketManager alloc]init]; manager.connetSocketResult = -1; // 初始化连接状态是断开状态 // 在创建单例的时候就去初始化Socket和开辟一条新的线程去接收消息 [manager initSocket]; [manager ReceiveMessageThread]; }); return manager; } -(void)initSocket{ //每次连接前,先判断是否在连接状态 0 在连接状态,直接Return if (_connetSocketResult == 0) { return; } _clientScoket = creatSocket(); //创建客户端socket const char * server_ip="127.0.0.1"; //服务器Ip short server_port = 6969; //服务器端口 //等于0说明连接成功,-1则连接失败 if (connectionToServer(_clientScoket,server_ip, server_port) == 0) { _connetSocketResult = 0; printf("Connect to server Success\n"); return ; }else{ _connetSocketResult = -1; printf("Connect to server error\n"); } } /** 创建Socket @return 返回Socket */ static int creatSocket(){ int ClinetSocket = 0; // NOTE: Socket本质上就是int类型 ClinetSocket = socket(AF_INET, SOCK_STREAM, 0); // 返回创建的Socket return ClinetSocket; } /** 连接 Socket @param client_socket Socket @param server_ip 服务器IP @param port 端口 @return 返回时候连接成功,返回0则连接成功,-1连接失败 */ static int connectionToServer(int client_socket,const char * server_ip,unsigned short port){ //生成一个sockaddr_in类型结构体 struct sockaddr_in sAddr={0}; sAddr.sin_len=sizeof(sAddr); //设置IPv4, 这个区分可以看前面在解释Socket方法的时候写的注释 sAddr.sin_family=AF_INET; //inet_aton是一个改进的方法来将一个字符串IP地址转换为一个32位的网络序列IP地址 //如果这个函数成功,函数的返回值非零,如果输入地址不正确则会返回零。 inet_aton(server_ip, &sAddr.sin_addr); //htons是将整型变量从主机字节顺序转变成网络字节顺序,赋值端口号 sAddr.sin_port=htons(port); // 防止发送SO_NOSIGPIPE信号导致崩溃 int nosigpipe = 1; setsockopt(client_socket, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, sizeof(nosigpipe)); //用scoket和服务端地址,发起连接。 //客户端向特定网络地址的服务器发送连接请求,连接成功返回0,失败返回 -1。 //注意:该接口调用会阻塞当前线程,直到服务器返回。 int connectResult = connect(client_socket, (struct sockaddr *)&sAddr, sizeof(sAddr)); return connectResult; } // 开辟一条线程接收消息 -(void)ReceiveMessageThread{ NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(recieveAction) object:nil]; [thread start]; } /** 连接Socket */ -(void)connectSocket{ [self initSocket]; } /** 断开Socket连接 */ -(void)disConnectSocket{ close(self.clientScoket); } /** 发送消息 @param msg 消息内容 */ - (void)sendMsg:(NSString *)msg{ const char * send_Message = [msg UTF8String]; send(self.clientScoket,send_Message,strlen(send_Message)+1,0); } /** 死循环,看是否有消息发送过来 */ - (void)recieveAction{ while (1) { char recv_Message[1024] = {0}; recv(self.clientScoket, recv_Message, sizeof(recv_Message), 0); printf("%s\n",recv_Message); } } @end
这个是服务端代码,先把代码给出来,下面我会仔细和大家说说你怎样把这个运行起来!
var net = require('net'); var HOST = '127.0.0.1'; var PORT = 6969; // 创建一个TCP服务器实例,调用listen函数开始监听指定端口 // 传入net.createServer()的回调函数将作为”connection“事件的处理函数 // 在每一个“connection”事件中,该回调函数接收到的socket对象是唯一的 net.createServer(function(sock) { // 我们获得一个连接 - 该连接自动关联一个socket对象 console.log('CONNECTED: ' + sock.remoteAddress + ':' + sock.remotePort); sock.write('服务端发出:连接成功'); // 为这个socket实例添加一个"data"事件处理函数 sock.on('data', function(data) { console.log('DATA ' + sock.remoteAddress + ': ' + data); // 回发该数据,客户端将收到来自服务端的数据 sock.write('You said "' + data + '"'); }); // 为这个socket实例添加一个"close"事件处理函数 sock.on('close', function(data) { console.log('CLOSED: ' + sock.remoteAddress + ' ' + sock.remotePort); }); }).listen(PORT, HOST); console.log('Server listening on ' + HOST +':'+ PORT);
服务端代码怎样运行
为什么要说这个呢,因为不是每一个iOS开发都是会懂那些JS的,上面的服务端代码就是我给大家推荐的第一篇文章中的,我自己写这试了试,这个运行起来推荐给大家的是这款 Sublime Text2 这款软件,这个 Sublime Text3是已经出了,但你能下载到MAC版本上面的只能是Sublime Text2,所以你可以先百度把这东西下载了!
接下来,你还需要安装它 Node.js , 下载并安装Node.js , 你下载安装之后,需要在 Sublime Text2添加支持JS 的 Build System , 下图选中这个 New Build System... ,添加下面代码:
{"cmd":["node","$file"],"selector":"source.js"}
NOTE: 要是你的node是一步一步点击直接安装的,上面代码中的node要变成你安装的node的绝对路径,怎么找node的绝对路径??
打开你的终端 which node 搞定!!
这篇博客写的也是花了许久的时间,由于篇幅的问题,不能再写一下去了,再写就真的太长了,后面的心跳、重连等等问题再下一篇文章中总结,在下一篇中打算通过结合CocosAsnySocket这个三方总结Socket剩下的问题。