iOS 开源库源码分析之GCDAsyncSocket
GCDAsyncSocket
连接
- 预连接,检查delegate,delegateQueue,是否已经连接,支持IPv4/IPv6,
- lookupHost,获取server地址
- lookup,建立连接
- connectWithAddress4:address6:,通过lookup调用
- 调用createSocket,创建客户端socket
- connectSocket,调用connect()函数连接服务器
服务器开始监听(acceptOnPort)
这里最终调用的是acceptOnInterface方法
- 通过getInterfaceAddress4:address6:获取本地的IPv4/IPv5 地址以及端口
- 创建IPv4/IPv6的socket,并且进行bind本机地址
- 调用listen函数进行监听这个socket
- 获取客户端连接回调,创一个DISPATCH_SOURCE_TYPE_READ的source并且启动,然后设置event handler,这里面会调用doAccept方法,在这个方法,通过accept()函数获取客户端新连接的socket,然后会异步到delegateQueue,调用代理方法socket:didAcceptNewSocket:来获取新的连接,
发送数据
发送数据的对象是GCDAsyncWritePacket
发送的方法是writeData:
- 异步到socketQueue,把GCDAsyncWritePacket对象加入到writeQueue
- 调用maybeDequeueWrite,取出writeQueue第一个数据为currentWrite
- 写入数据有三种方式
- 通过write()函数正常写入
- TLS方式,通过CFWriteStreamWrite写入
- SSL方式,通过SSLWrite写入,在这里面如果遇到了I/O阻塞(errSSLWouldBlock),会把数据放入缓冲区进行再次写入
TCP粘包
TCP是面向连接的传输层协议,TCP连接只能是一对一的,它提供可靠的交付服务,也就是说,通过TCP连接传送的数据,无差错、不丢失、不重复、并且按序到达,TCP提供全双工通信,TCP是面向字节流的,无消息保护边界,TCP把应用程序交下来的数据块看成无结构的字节流,TCP不保证接收方应用程序收到的数据块和发送方应用程序所发出的数据块具有对应的大小关系(例如,发送方应用程序交给发送方TCP共10个数据块,但接收方的TCP可能只用4个数据块就把收到的字节流交付给了上层的应用程序,但接收方应用程序收到的字节流必须和发送方应用程序发出的字节流完全一样)。
基于上面TCP所以会有粘包的问题,而UDP基于数据包则不会出现粘包。但是由于UDP传输不可靠,会出现丢包,无序的问题,而TCP则不会有。
解决思路:
- 包尾加上\r\n分隔符,作为消息保护边界,缺点是内容中有\r\n会有误判
- 使用包头,包头内加上包体长度
包头可以是一个字典,里面加上包体的长度的key/value即可。
在包头后面加上[GCDAsyncSocket CRLFData]就是\r\n了,作为分隔符,HTTP就是使用\r\n作为包头分隔符的。
在服务端可以在接受连接的时候
[newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
来读取一个包头。
获取到包头后,拿到大小,通过下面的方法就可以拿到包体了。
[sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
心跳
心跳机制是判断客户端与服务端双方是否存活。
好处是:断了之后客户端可以重新建连,而对于服务端来说清理无效连接。
心跳客户端和服务器都可以发起,一般来说客户端发,一般实现步骤如下
- 客户端每隔一个时间发送一个包给服务器,并且设置超时时间
- 服务器收到后回应一个包
- 客户端如果收到包则说明正常,超时的话则说明挂了
客户端负载均衡
负载均衡(Load Balance)一般指服务端负载均衡,意思是将负载(工作任务,访问请求)进行平衡、分摊到多个操作单元(服务器,组件)上进行执行。对于服务端负载均衡一般都会维护一个服务器清单,当客户端请求到来时负载均衡服务器会从清单中选出一台处理请求。
而客户端负载均衡最大的区别在于服务器清单是在客户端维护。
实现步骤:
- 获取服务器列表并且缓存在本地
- 对每个服务器进行连接并且发送测速包
- 当全部拿到测速结果(一次来回的时间等)后,找到最优的服务器进行连接
TLS
关于ssl的握手过程可以参考我之前写的SSL。
可以看一下下面方法,表示开启TLS
- (void)startTLS:(NSDictionary *)tlsSettings
里面有很多key,比如
- GCDAsyncSocketManuallyEvaluateTrust,如果是YES,表示需要手动验证证书,在socket:didReceiveTrust:completionHandler: delegate方法里面去实现,可以参考stackoverflow的实现
- GCDAsyncSocketUseCFStreamForTLS,仅限iOS,使用CFStream的TLS
startTLS中首先根据tlsSettings构造GCDAsyncSpecialPacket,把packet加入读写队列,这里最终调用的是maybeStartTLS方法,如果不是使用SecureTransport,则调用cf_startTLS方法,如果使用SecureTransport,则调用ssl_startTLS方法。
在ssl_startTLS中
初始化SSL上下文
设置SSL读写的回调
建立SSL连接
当然还有一些其他可选项,比如如果是GCDAsyncSocketManuallyEvaluateTrust的话,会调用SSLSetSessionOption()函数
之后调用ssl_continueSSLHandshake开始握手,调用SSLHandshake()函数进行握手
如果返回的status为noErr,表示握手成功
status为errSSLPeerAuthCompleted,首先会调用SSLCopyPeerTrust(sslContext, &trust)获取到证书,然后传递给delegate的didReceiveTrust:方法,然后在里面验证证书,然后调用ssl_continueSSLHandshake继续握手
errSSLWouldBlock,表示握手继续,需要继续调用ssl_continueSSLHandshake
NAT穿透
NAT(Network Address Translation,网络地址转换),也叫做网络掩蔽或者IP掩蔽,是一种在IP数据包通过路由器或防火墙时重写来源IP地址或目的IP地址的技术。这种技术被普遍使用在有多台主机但只通过一个公有IP地址访问因特网的私有网络中。NAT功能通常被集成到路由器、防火墙或者单独的NAT设备中。
先说一下UDP打洞,现有A,B两台设备,N1,N2两个NAT,以及一个服务器S。
- A和B分别和S建立UDP连接,服务器知道A,B各自的外网IP和端口
- A通过S知道了B的外网IP和端口,A向B的外网地址发送消息,B的NAT设备N2会拒收这条消息,不过N1会增加一条允许规则,允许接受从B过来的消息
- 服务器要求B发送一个消息到A的外网IP与端口,这时A就可以接受B的消息,而且N2会允许接受从A过来的消息
TCP则有一点不同,不过思路上是一致的,因为对于UDP来说多个socket可以对应一个端口号(只需要A到S的socket和A到B的socket对应到一个端口就可以打洞成功),而TCP一个socket只能对应一个端口。
针对这个的解决方法是可以通过setsockopt()设置SO_REUSEADDR为参数来进行端口重用,然后就可以打洞成功了。
Protocol Buffers
protobuf作为数据交换的格式有几个优点
- 前后兼容好
- 得益于编码方式,数据量小
- 反序列化速度快于JSON
所以如果不在意消息格式是二进制导致可读性差的话,在有些情况下网络通信的数据格式会选择protobuf
protobuf消息的格式如下
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
这是一个.proto文件,在ObjC中需要编译成.pb.h和.pb.m文件。
在修改proto文件的时候,它能够非常好的前后兼容
- 每个字段必须使用唯一的数字标签,删除了字段的数字标签也不能再用
- required,不能修改的,并且不能增加和删除
- optional,可以有0个或1个值,可以删除和增加
- repeated,可以有0个或者多个值,相当于Array,可以删除和增加
编码
Base 128 Varints,就是protobuf来序列化整型数据的一种编码方式。
经过Varints编码后的数据,每一个字节8bit的高位代表下一个标记位,如果为1,则表示下一个字节仍然是当前整型数据的组成,为0就是下一个数据。
显然300,经过Varints编码后的序列为:
1010 1100 0000 0010
从左到右依次去掉每个字节高位(标识位)
1010 1100 0000 0010
→ 010 1100 000 0010
因为protobuf是以little endian(它认为第一个字节是最低位字节,按照从低地址到高地址的顺序存放据的低位字节到高位字节)来编码字节序,所以这里交换一下两个字节,就可以得到原始数据
000 0010 010 1100
→ 000 0010 ++ 010 1100
→ 100101100
→ 256 + 32 + 8 + 4 = 300
接下来说一下消息结构,protobuf消息是经过编码序列化的一系列key-value对,一个类型的数据对应一个key-value。value是原始数据经过编码后的数据,key由field number 和 wire type组成。
message Test
{
required int32 id = 1; // (1为field number)
}
Test类型的id字段的field number 就是1,wire type表示变量类型的序号(整型为0,表示用Varint编码;double为1,string为2,详情查看官网)
key是通过移位再相或将field number 和 wire type 用一个字节存储
(field_number << 3) | wire_type
即低三位表示wire type,其他的位表示field number。
那么key的表示就是
0000 1000
第一个字节为08
如果id值为150的话,它经过Varint编码后的序列是:
96 01 = 1001 0110 0000 0001
→ 000 0001 ++ 001 0110 (drop the msb and reverse the groups of 7 bits)
→ 10010110
→ 2 + 4 + 16 + 128 = 150
所以最后这个消息编码序列化后以16进制输出,最后会得到三个字节
08 96 01
为什么protobuf更小?
Varint 不是为了空间变小吗?那 300 本来可以用 2 个字节表示(10010110),现在还是 2 个字节了(1010 1100 0000 0010),并没有变小。
但Varint 确实是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。
300 如果用 int32 表示,需要 4 个字节,现在用 Varint 表示,只需要 2 个字节了。缩小了一半!
另外和JSON相比没有一些{},""等,并且protobuf的key 是由field number 和 wire type组成只有一字节,所以更小
为什么序列化快?
protobuf因为是key/value形式,key中就包含了value的数据类型,比如bool就是一个字节,那么程序直接从后面读一个字节就可以解析出value;但json需要进行字符串解析才可以。