iOS即时通讯进阶 – CoacoaAsyncSocket源码解析

112702646-53f19ad8391e4a98
前言:

CoacoaAsyncSocket是谷歌的开发者,基于BSD-Socket写的一个IM框架,它给Mac和iOS提供了易于使用的、强大的异步套接字库,向上封装出简单易用OC接口。省去了我们面向Socket以及数据流Stream等繁琐复杂的编程。
本文为一个系列,旨在让大家了解CoacoaAsyncSocket是如何基于底层进行封装、工作的。

注:文中涉及代码比较多,建议大家结合源码一起阅读比较容易能加深理解。这里有楼主标注好注释的源码,有需要的可以作为参照:CoacoaAsyncSocket源码注释

如果对该框架用法不熟悉的话,可以参考楼主之前这篇文章:iOS即时通讯,从入门到“放弃”?,或者自行查阅。

正文:
首先我们来看看框架的结构图:
  • 122702646-d0a8cb442e961943

整个库就这么两个类,一个基于TCP,一个基于UDP。其中基于TCP的GCDAsyncSocket,大概8000多行代码。而GCDAsyncUdpSocket稍微少一点,也有5000多行。
所以单纯从代码量上来看,这个库还是做了很多事的。

顺便提一下,之前这个框架还有一个runloop版的,不过因为功能重叠和其它种种原因,后续版本便废弃了,现在仅有GCD版本。

本系列我们将重点来讲GCDAsyncSocket这个类。

我们先来看看这个类的属性:

这个里定义了一些属性,可以先简单看看注释,这里我们仅仅先暂时列出来,给大家混个眼熟。
在接下来的代码中,会大量穿插着这些属性的使用。所以大家不用觉得困惑,具体作用,我们后面会一一讲清楚的。

接着我们来看看本文方法一–初始化方法:

详细的细节可以看看注释,这里初始化了一些属性:

1.代理、以及代理queue的赋值。

2.本机socket的初始化:包括下面3种

其中值得一提的是第三种:UnixSocket,这个是用于Unix Domin Socket通信用的。
那么什么是Unix Domain Socket呢?
原来它是在socket的框架上发展出一种IPC(进程间通信)机制,虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC 更有效率 :

  • 不需要经过网络协议栈
  • 不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

基本上它是当今应用于IPC最主流的方式。至于它到底和普通的socket通信实现起来有什么区别,别着急,我们接着往下看。

3.生成了一个socketQueue,这个queue是串行的,接下来我们看代码就会知道它贯穿于这个类的所有地方。所有对socket以及一些内部数据的相关操作,都需要在这个串行queue中进行。这样使得整个类没有加一个锁,就保证了整个类的线程安全。

4.创建了两个读写队列(本质数组),接下来我们所有的读写任务,都会先追加在这个队列最后,然后每次取出队列中最前面的任务,进行处理。

5.创建了一个全局的数据缓冲区:preBuffer,我们所操作的数据,大部分都是要先存入这个preBuffer中,然后再从preBuffer取出进行处理的。

6.初始化了一个交替延时变量:alternateAddressDelay,这个变量先简单的理解下:就是进行另一个服务端地址请求的延时。后面我们一讲到,大家就明白了。

初始化方法就到此为止了。

132702646-f4c2854b83972659

接着我们有socket了,我们如果是客户端,就需要去connect服务器。

又或者我们是服务端的话,就需要去bind端口,并且accept,等待客户端的连接。(基本上也没有用iOS来做服务端的吧…)

这里我们先作为客户端来看看connect
  • 142702646-13ebbf0af3a0f12b

    connect.png

其中和connect相关的方法就这么多,我们一般这么来连接到服务端:

也就是我们在截图中选中的方法,那我们就从这个方法作为起点,开始讲起吧。

本文方法二–connect总方法

这个方法非常长,它主要做了以下几件事:

  • 首先我们需要说一下的是,整个类大量的会出现LogTrace()类似这样的宏,我们点进去发现它的本质只是一个{},什么事都没做。

    原来这些宏是为了追踪当前执行的流程用的,它被定义在一个大的#if #else中:

    而此时因为GCDAsyncSocketLoggingEnabled默认为0,所以仅仅是一个{}。当标记为1时,这些宏就可以用来输出我们当前的业务流程,极大的方便了我们的调试过程。

  • 接着我们回到正题上,我们定义了一个Block,所有的连接操作都被包裹在这个Block中。我们做了如下判断:

    保证这个连接操作一定是在我们的socketQueue中,而且还是以串行同步的形式去执行,规避了线程安全的问题。

  • 接着把Block中连接过程产生的错误进行赋值,并且把连接的结果返回出去

接着来看这个方法声明的Block内部,也就是进行连接的真正主题操作,这个连接过程将会调用许多函数,一环扣一环,我会尽可能用最清晰、详尽的语言来描述…

1.这个Block首先做了一些错误的判断,并调用了一些错误生成的方法。类似:

2.接着做了一个前置的错误检查:

这个检查方法,如果没通过返回NO。并且如果interface有值,则会将本机的IPV4 IPV6的 address设置上。即我们之前提到的这两个属性:

我们来看看这个前置检查方法:

本文方法三–前置检查方法

又是非常长的一个方法,但是这个方法还是非常好读的。

  • 主要是对连接前的一个属性参数的判断,如果不齐全的话,则填充错误指针,并且返回NO。
  • 在这里如果我们interface这个参数不为空话,我们会额外多执行一些操作。
    首先来讲讲这个参数是什么,简单来讲,这个就是我们设置的本机IP+端口号。照理来说我们是不需要去设置这个参数的,默认的为localhost(127.0.0.1)本机地址。而端口号会在本机中取一个空闲可用的端口。
    而我们一旦设置了这个参数,就会强制本地IP和端口为我们指定的。其实这样设置反而不好,其实大家也能想明白,这里端口号如果我们写死,万一被其他进程给占用了。那么肯定是无法连接成功的。
    所以就有了我们做IM的时候,一般是不会去指定客户端bind某一个端口。而是用系统自动去选择。
  • 我们最后清空了当前读写queue中,所有的任务。

至于有interface,我们所做的额外操作是什么呢,我们接下来看看这个方法:

本文方法四–本地地址绑定方法

这个方法中,主要是大量的socket相关的函数的调用,会显得比较难读一点,其实简单来讲就做了这么一件事:
interface变成进行socket操作所需要的地址结构体,然后把地址结构体包裹在NSMutableData中。

这里,为了让大家能更容易理解,我把这个方法涉及到的socket相关函数以及宏(按照调用顺序)都列出来:

还有一些用到的作为参数的结构体:

这一段内容算是比较枯涩了,但是也是了解socket编程必经之路。

这里提到了网络字节序和主机字节序。我们创建socket之前,必须把port和host这些参数转化为网络字节序。那么为什么要这么做呢?

不同的CPU有不同的字节序类型 这些字节序是指整数在内存中保存的顺序 这个叫做主机序
最常见的有两种
1. Little endian:将低序字节存储在起始地址
2. Big endian:将高序字节存储在起始地址

这样如果我们到网络中,就无法得知互相的字节序是什么了,所以我们就必须统一一套排序,这样网络字节序就有它存在的必要了。

网络字节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关。从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节顺序采用big endian排序方式。

大家感兴趣可以到这篇文章中去看看:网络字节序与主机字节序

除此之外比较重要的就是这几个地址结构体了。它定义了我们当前socket的地址信息。包括IP、Port、长度、协议族等等。当然socket中标识为地址的结构体不止这3种,等我们后续代码来补充。

大家了解了我们上述说的知识点,这个方法也就不难度了。这个方法主要是做了本机IPV4IPV6地址的创建和绑定。当然这里分了几种情况:

  1. interface为空的,我们作为客户端不会出现这种情况。注意之前我们是这个参数不为空才会调入这个方法的。
    而这个一般是用于做服务端监听用的,这里的处理是给本机地址绑定0地址(任意地址)。那么这里这么做作用是什么呢?引用一个应用场景来说明:

    如果你的服务器有多个网卡(每个网卡上有不同的IP地址),而你的服务(不管是在udp端口上侦听,还是在tcp端口上侦听),出于某种原因:可能是你的服务器操作系统可能随时增减IP地址,也有可能是为了省去确定服务器上有什么网络端口(网卡)的麻烦 —— 可以要在调用bind()的时候,告诉操作系统:“我需要在 yyyy 端口上侦听,所有发送到服务器的这个端口,不管是哪个网卡/哪个IP地址接收到的数据,都是我处理的。”这时候,服务器程序则在0.0.0.0这个地址上进行侦听。

  2. 如果interfacelocalhost或者loopback则把IP设置为127.0.0.1,这里localhost我们大家都知道。那么什么是loopback呢?
    loopback地址叫做回环地址,他不是一个物理接口上的地址,他是一个虚拟的一个地址,只要路由器在工作,这个地址就存在.它是路由器的唯一标识。
    更详细的内容可以看看百科:loopback
  3. 如果是一个其他的地址,我们会去使用getifaddrs()函数得到本机地址。然后去对比本机名或者本机IP。有一个能相同,我们就认为该地址有效,就进行IPV4和IPV6绑定。否则什么都不做。

至此这个本机地址绑定我们就做完了,我们前面也说过,一般我们作为客户端,是不需要做这一步的。如果我们不绑定,系统会自己绑定本机IP,并且选择一个空闲可用的端口。所以这个方法是iOS用来作为服务端调用的。

方法三–前置检查、方法四–本机地址绑定都说完了,我们继续接着之前的方法二往下看:

之前讲到第3点了:
3.这里把flag标记为kSocketStarted:

源码中大量的运用了3个位运算符:分别是或(|)、与(&)、取反(~)、运算符。 运用这个标记的好处也很明显,可以很简单的标记当前的状态,并且因为flags所指向的枚举值是用左位移的方式:

所以flags可以通过|的方式复合横跨多个状态,并且运算也非常轻量级,好处很多,所有的状态标记的意义可以在注释中清晰的看出,这里把状态标记为socket已经开始连接了。

4.然后我们调用了一个全局queue,异步的调用连接,这里又做了两件事:

  • 第一步是拿到我们需要连接的服务端server的地址数组:
  • 第二步是做一些错误判断,并且把地址信息赋值到address4address6中去,然后异步调用回socketQueue去用另一个方法去发起连接:

    在这个方法中我们可以看到作者这里把创建server地址这些费时的逻辑操作放在了异步线程中并发进行。然后得到数据之后又回到了我们的socketQueue发起下一步的连接。

然后这里又是两个很大块的分支,首先我们来看看server地址的获取:

本文方法五–创建服务端server地址数据:

这个方法根据host进行了划分:

  1. 如果hostlocalhost或者loopback,则按照我们之前绑定本机地址那一套生成地址的方式,去生成IPV4和IPV6的地址,并且用NSData包裹住这个地址结构体,装在NSMutableArray中。
  2. 不是本机地址,那么我们就需要根据host和port去创建地址了,这里用到的是这么一个函数:

    这个函数主要的作用是:根据hostname(IP)service(port),去获取地址信息,并且把地址信息传递到result中。
    而hints这个参数可以是一个空指针,也可以是一个指向某个addrinfo结构体的指针,如果填了,其实它就是一个配置参数,返回的地址信息会和这个配置参数的内容有关,如下例:

    举例来说:指定的服务既可支持TCP也可支持UDP,所以调用者可以把hints结构中的ai_socktype成员设置成SOCK_DGRAM使得返回的仅仅是适用于数据报套接口的信息。

    这里我们可以看到result和hints这两个参数指针指向的都是一个addrinfo的结构体,这是我们继上面以来看到的第4种地址结构体了。它的定义如下:

    我们可以看到它其中包括了一个IPV4的结构体地址ai_addr,还有一个指向下一个同类型数据节点的指针ai_next
    其他参数和之前的地址结构体一些参数作用类似,大家可以对着注释很好理解,或者仍有疑惑可以看看这篇:
    socket编程之addrinfo结构体与getaddrinfo函数
    这里讲讲ai_next这个指针,因为我们是去获取server端的地址,所以很可能有不止一个地址,比如IPV4、IPV6,又或者我们之前所说的一个服务器有多个网卡,这时候可能就会有多个地址。这些地址就会用ai_next指针串联起来,形成一个单链表。

    然后我们拿到这个地址链表,去遍历它,对应取出IPV4、IPV6的地址,封装成NSData并装到数组中去。

  3. 如果中间有错误,赋值错误,返回地址数组,理清楚这几个结构体与函数,这个方法还是相当容易读的,具体的细节可以看看注释。

接着我们回到本文方法二,就要用这个地址数组去做连接了。

这里调用了我们本文方法六–开始连接的方法1

这个方法也比较简单,基本上就是做了一些错误的判断。比如:

  1. 判断在不在这个socket队列。
  2. 判断传过来的aStateIndex和属性stateIndex是不是同一个值。说到这个值,不得不提的是大神用的框架,在容错处理上,做的真不是一般的严谨。从这个stateIndex上就能略见一二。
    这个aStateIndex是我们之前调用方法,用属性传过来的,所以按道理说,是肯定一样的。但是就怕在调用过程中,这个值发生了改变,这时候整个socket配置也就完全不一样了,有可能我们已经置空地址、销毁socket、断开连接等等…等我们后面再来看这个属性stateIndex在什么地方会发生改变。
  3. 判断config中是需要哪种配置,它的参数对应了一个枚举:

    前3个大家很好理解,无非就是用IPV4还是IPV6。
    而第4个官方注释意思是,我们即使关闭读的流,也会保持Socket开启。至于具体是什么意思,我们先不在这里讨论,等后文再说。
这里调用了我们本文方法七–开始连接的方法2

这个方法也仅仅是连接中过渡的一个方法,做的事也非常简单:

  1. 就是拿到IPV4和IPV6地址,先去创建对应的socket,注意这个socket是本机客户端的,和server端没有关系。这里服务端的IPV4和IPV6地址仅仅是用来判断是否需要去创建对应的本机Socket。这里去创建socket会带上我们之前生成的本地地址信息connectInterface4或者connectInterface6
  2. 根据我们的config配置,得到主选连接和备选连接。 然后先去连接主选连接地址,在用我们一开始初始化中设置的属性alternateAddressDelay,就是这个备选连接延时的属性,去延时连接备选地址(当然如果主选地址在此时已经连接成功,会再次连接导致socket错误,并且关闭)。

这两步分别调用了各自的方法去实现,接下来我们先来看创建本机Socket的方法:

本文方法八–创建Socket:

这个方法做了这么几件事:

  1. 创建了一个socket:

    其实这个函数在之前那篇IM文章中也讲过了,大家参考参考注释看看就可以了,这里如果返回值为-1,说明创建失败。
  2. 去绑定我们之前创建的本地地址,它调用了另外一个方法来实现。
  3. 最后我们调用了如下函数:

    那么这个函数是做什么用的呢?简单来说,它就是给我们的socket加一些额外的设置项,来配置socket的一些行为。它还有许多的用法,具体可以参考这篇文章:setsockopt函数

    而这里的目的是为了来避免网络错误而出现的进程退出的情况,调用了这行函数,网络错误后,系统不再发送进程退出的信号。
    关于这个进程退出的错误可以参考这篇文章:Mac OSX下SO_NOSIGPIPE的怪异表现

未完总结:

connect篇还没有完结,奈何篇幅问题,只能断在这里。下一个方法将是socket本地绑定的方法。再下面就是我们最终的连接方法了,历经九九八十一难,马上就要取到真经了…(然而这仅仅是一个开始…)
下一篇将会承接这一篇的内容继续讲,包括最终连接、连接完成后的source和流的处理。
我们还会去讲讲iOS作为服务端的accpet建立连接的流程。
除此之外还有 unix domin socket(进程间通信)的连接。

最近总感觉很浮躁,贴一句一直都很喜欢的话:
上善若水。水善利万物而不争

  • 152702646-66af41f3d8977986
1 4 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部