c: Hello, i'm black-flower, is anybody there?
s: Yes, i'm white-flower, do you still at there? Mr.black-flower.
c: Yes, yes, i'm here! Now, Mr.white-flower, let's dancing!
s: Uh......mdzz!
.................. Lost Connection ..................
前言
本文基于CocoaAsyncSocket从TCP连接的建立到请求结果的处理为你概述如何构建一个方便易用的iOS网络层, 全文约8千字, 预计花费阅读时间20 - 30分钟.
目录
建立一个可靠的网络连接
1.连接的定义
2.连接的建立与关闭
3.自动重连处理
自定义网络任务
1.自定义网络协议
2.根据协议制定请求
3.化请求为任务
网络任务的派发
1.任务的派发
2.任务的取消
3.多服务器的切换
合理的使用请求派发器
一.建立一个可靠的网络连接
1.连接的定义
在介绍网络连接前, 我们先描述一下打电话的过程. 我们在打电话之前一定是要先接通到对方, 然后才开始聊天, 没有谁对着电话先来两段单口, 再去拨号的. 接通以后, 我们通过听筒收听对方的语音, 通过话筒发送自己的语音. 另外, 在通话过程中如果出现信号波动导致连接断开, 我们通常会马上回拨过去以继续通话. 最后, 在通话结束时, 我们或者对方就会主动断开此次通话连接.
在一定程度上, 网络通信和打电话是差不多的. 在通信过程中, 拨号就对应着建立网络连接, 回拨和挂断对应着重连和断开连接, 听筒是接收数据, 而话筒则是发送数据. 那么根据以上描述, 我们的网络连接定义如下:
2.连接的建立与关闭
接口定义见名知意就不解释了, 我们看看具体实现:
内部我们使用GCDAsyncSocket去完成实际的连接过程, 建立一个GCDAsyncSocket需要一个delegate和delegateQueue, 我们把delegate设置为自己, delegateQueue设置为入参或者自建队列. 另外GCDAsyncSocket基于runloop处理数据读取, 我们不希望这个读取过程影响到UI的流畅度, 所以分配一个socketThread去处理这些事情.
有了socket以后我们就可以进行连接了:
在连接前我们先断开之前的连接. 连接时需要一个服务器地址和端口号, 这部分和上一篇的HTTP连接是一样的, 不再赘述. 另外, GCDAsyncSocket的连接方法是一个同步执行过程, 所以我们把连接也放到之前建立的socketThread中去. 最后, 如果连接失败的话我们会调用reconnect进行重连, 连接成功就开始读取数据并将数据发送到外部. 至于代码里面的delayTime会在下文进行介绍.
3.自动重连处理
在介绍自动重连之前,我们先介绍一下心跳保活机制,一般的心跳分两种,单向的ping机制和双向的ping-pong机制。
具体的, ping机制指发送方定时向另一方发送一个心跳包, 接收方接收到后就知道对方在线, 同时回复一个心跳包, 发送方收到回复后也可以确定对方在线, 否则说明对方不在线, 那么发送方就会主动断开连接.一般这个发送方是客户端。
单向心跳的问题在于, 接收方是处于被动地位, 在收到心跳稍后到下一次心跳到达之前的中间间隔时间它并不能确定对方是否还在线, 典型的情况就是电梯隧道之类的场景, 连接虽在但信号太弱, 不足以进行网络通信. 由此引出ping-pong机制, 服务端在发出消息后会要求接收方回复一个心跳, 如果在规定时间内没有收到这个回复, 那么认为消息发送失败, 断开这个无效连接等待客户端重连, 如果是重要的消息则考虑转由APNS推送到客户端. 同样的, 如果是客户端发送请求后, 规定时间内未收到服务器的回复, 那么就是一个超时错误返回给调用页面, 多次超时也需要主动断开连接然后重连。
回到重连的问题上, 作为接收方的客户端如果被服务器主动断掉, 这说明网络可能有问题, 稍后重连就是. 但如果是客户端主动断开连接除了网络问题外还有可能是服务器此时已经过载或者挂掉, 无力回复心跳. 那么此时如果所有的客户端断掉后马上同时进行连接, 那么刚刚恢复的服务器面对这几十万同时到来的连接马上又会被搞垮, 恶性循环. 这也是为什么在connect方法里面对于连接的delayTime会有一个随机数的原因.
二. 自定义网络任务
上面的流程走完以后我们就能得到一个自管理的的socket连接, 因此, 我们不用关注各种连接逻辑的处理, 而是可以专注于网络数据的收发.
数据在TCP中是以流的形式进行传递的, 多个数据包首尾相连不分彼此在同一个流中进行传输, 这样的数据流即使到了接收端也并不能被正确解析(也就是粘包), 因此, 在数据打包发送之前, 我们需要给数据包拼装上标记, 以区分各个数据包的边界, 这个标记就是数据包的包头. 基于TCP的HTTP就是在每次请求之前自动加上了这些数据头, 所以通常我们只需要提供请求数据不需要关心数据头, 因为这些在HTTP中已经处理好了. 但是当我们直面TCP时, 这些都需要我们自己去处理.
1.自定义网络协议
根据上面的描述, 定义网络请求会分为两个部分, 请求头和请求体, 一般请求体拼装在请求头后面, 请求体就是请求对应的参数/数据, 而请求头就是此次数据包的描述, 必要的字段包括: 此次请求的操作(messageType, 类比HTTP的URL), 请求序列号(messageSerialNum, 请求唯一标识), 请求体长度(messageContentLength, 防粘包). 其他的字段多是跟公司业务直接挂钩, 比如用于数据校验的checkSum或者请求尾, HTTP中常用于自动登录的sessionId, 标识资源改变状态的ETag和Last-Modified等等...
另外请求头和请求体的拼装也有两种, 直接拼装和分隔符拼装.
直接拼装的格式如下:
分隔符拼装的格式如下(分隔符 ==
):
两者的共同点: 两种拼装方式的请求头的长度和头里面各个字段的位置和长度都是定值, 这样收到数据的一方才能进行解析。
两者的不同点: 直接拼装的方式是先取到请求头, 再根据请求头的Length字段去截取后面的请求体, 最后通过checkSum或者请求尾校验数据, 有问题就抛弃, 这个过程要求必须能正确拿到请求头的Length字段同时请求体也完整的收到(即不能丢包), 不然后续的解析全都会出问题, 只能重连. 而分隔符拼装的方式通常是先读第一个 拿到请求头, 再读一个 拿到请求体, 两者通过比对有问题就丢掉, 没问题就处理, 即使出现丢包, 只要不是 丢掉, 后续的解析都不会有问题, 不用重连.
我司采用的是直接拼装的方式, 至于为什么, 我来公司前他们就这样干了. 不过好在TCP本身是可靠连接, 有各种机制保障数据完整到达, 所以丢包的概率很小, 倒是没出过什么问题.
2.根据协议制定请求
根据以上描述, 我们需要自定义一个网络请求类, 提供给它相应的请求头和请求体, 输出排列好的请求数据包. 以数据请求为例:
因为请求类型(即URL)非常重要, 所以单独拎出来作为一个参数提醒非传不可, 而请求头中其他的非必要字段都声明在HHSocketRequestHeader中以header入参, 至于请求体, 我司用的是Google的ProtocolBuffers, 网上资料很多, 不做赘述。
这里根据数据请求的操作类型定义了三个接口, 因为心跳请求是不携带任何信息的, 所以只需要一个序列号, 只有实际的数据请求才会三个参数都需要, 至于取消请求会在下文介绍. 来看看具体实现:
我们参照NSURLSessionTask给每个请求一个递增的identifier做请求序列号, 这个Identifier标志所有从客户端发出的请求, 而像心跳和在线推送这些请求不在此列, 而是定义在HHSocketRequestType枚举中, 这样, 当解析出请求序列号时我们就可以根据序列号的定义规则判断此时是数据请求的Response还是心跳抑或是服务器推送了, 这里我们预留50个序列号方便以后拓展。
HHDataFormatter是一个int/data互转的工具类, 内部走的都是同一个实现, 外部给出多个接口提高可读性和拓展性, 另外注意一下int转data有个大小端问题(即网络字节序和主机字节序), 其他就没什么好说的了.
3.化请求的为任务
以上的请求只是对一次网络操作的描述, 它只知道自己要做什么操作, 但是不知道什么时候会被发起, 什么时候被取消, 操作完成后又该做什么. 那么对于请求请求的具体管理, 我们定义一个HHSocketTask:
HHSocketTask作为一次网络任务的抽象, 内部负责管理任务执行的状态, 任务执行结果的回调, 外部暴露任务派发和取消的接口. 现在就等着谁来调用这些接口了.
三. 网络任务的派发
现在我们有了HHSocketRequest和HHSocketTask, 接下来的套路和HTTP篇的套路类似, 我们需要一个派发器来派发任务, 在任务派发前保持这个执行中的任务以处理任务需要取消的情况, 在派发后则删除这个任务. 照例, 定义一个单例:
这里的设计其实有点问题, 按理一个socket连接就应该对应一个数据接收端和数据派发表, 但是我司只有一个用于数据请求的连接(估计大部分小公司都是这样), 所以我就偷个懒定义成属性了. 各个属性见名知意, 不做赘述. 下面看看接口的实现:
这部分代码和HTTP篇基本一致, 只多了task.client = self这一行, 这个会在下文介绍, 任务的派发都是直接调用task.resume方法. 接下来看看resume的实现:
1.任务的派发
这里稍微有点绕, 我们一步一步来看:
Task.resume
resume内部首先判断任务是否可用(即是否在未派发前被外部取消), 可用的话设置任务状态为派发中, 然后调用self.client.resumeTask, 这个self.client是在上文中通过HHSocketClient.dataTaskWithRequest派发task时我们赋值的(就是那多出来的一行task.client = self).
HHSocketClient.resumeTask
resumeTask方法判断此时Socket的连接状态, 若连接可用就将序列化好的数据包写入到连接中来执行实际的请求派发, 请求派发成功后服务器会返回响应数据, 返回的响应数据会在socket:didReadData:里面进行接收, 若连接不可用时直接执行Task.completeWithResponseData:error方法.
HHSocketClient.socket:didReadData:
在接收到服务器返回的数据后, 先将心跳的计时重置, 然后追加data到self.readData中, 接下来调用getParsedResponseData从self.readData中获取解析出的数据包(此数据包包括数据头和数据体), 如果有解析到完整的数据包, 先判断此次返回的数据包序列号是否在我们的dispatchTable中(即判断是否是请求响应数据), 如果是请求响应数据那么调用Task.completeWithResponseData:error, 否则的话就是服务器发过来的推送或者心跳, 做相应处理即可.
HHSocketClient.getParsedResponseData
这个方法根据数据拼装规则获取服务器响应数据头中的msgLength, 根据这个msgLength截取出完整的数据包返回.
Task.completeWithResponseData:error
这个方法是请求的最终归宿, 无论是Socket连接不可用还是服务器响应数据都会到这里来处理, 具体的, 该方法先判断任务状态, 如果是未处理的任务(即任务未被取消), 通过HHSocketResponseFormatter解析数据包头和数据包体, 解析出来的数据包头包括一个responseCode, 这个字段表示我们发出的请求服务器是否能处理, 正常情况是200, 否则就是taskErrorWithResponeCode:中的错误码, responseCode正确后我们再通过adler32判断返回数据的完整性, 最后去执行Task.completeWithResult:error
Task.completeWithResult:error
此方法会根据入参执行Task初始化时的completionHander, completionHandler的执行呢会先经过HHSocketClient, HHSocketClient在这里将task从派发表中移除, 然后才会执行实际传入的handler(即调用方实际想要执行的代码), 并在completionHandler执行完成后将其置nil, 破除循环引用. 另外这里有个self.timer.invalidate会在下文介绍
总结一下整个请求过程中的数据流动:
发起请求: 调用方传参->HHSocketRequest将参数序列化->HHSocketTask将request.data发给HHSocketClient->HHSocketClient通过HHSocket发起实际请求
收到请求响应: HHSocket收到数据回调HHSocketClient-> HHSocketClient判断响应数据类型及完整性并将完整数据包传给Task->Task根据数据包进行拆包解析出result和error执行completionHandler->competitionHandler使派发器移除Task引用->调用方收到请求的result和error
看过我上一篇文章的朋友应该会发现, 这个Socket派发器和HTTP的派发器不同.
第一是保持Task的时机提前了, 并不是在实际派发的dispatchTask:去保持, 而是一开始Task的建立就保持, 这是因为并不是所有的Task都一定会走dispatchTask:方法, 如果将保持的时机放到此处, 那么那些不走dispatchTask:的方法就不会被保持, 响应数据返回时就会被误认为已经处理过的请求, 这是不对的.
第二是通用错误的处理被放在了Task中, 而不是派发器里, 这是因为HTTP派发器派发的系统的Task, 系统的Task.completionHandler直接返回的是(data,response,error), 也就是说数据在返回前的第一道处理是系统, 然后才是我们, 我们对他的处理结果不满意, 才有了二次处理. 但Socket的派发器派发的Task的第一道处理者就是我们自己, 又因为Task本来就要对数据拆分解析, 自然就随便把错误格式化咯. 另外最重要的一点是, 派发器本身不知道什么是正确的Task返回什么又是错误的返回, 它只负责Task的派发和取消, 如果不是因为懒, 甚至返回数据的完整性检查也不该在这里做, 所谓职责分离就是如此, 只做自己知道的事, 不做自己不应该知道的事情.
最后一点, 上面有些方法没有在相应类的.h文件中声明, 因为OC没有友元函数这个概念, 所以像Task.setClient, HHSocket.resumeTask之类的给特定类使用的友元接口我都定义在相应的xxx+friend.h文件中了
2.任务的取消
自定义的任务和HTTP的任务不同, HTTP一次任务就是一个连接(当然在HTTP1.1和HTPP2中多个任务也能共用一个连接了), 任务的取消直接断掉连接就是了. 但是我们的任务都是跑在在一个连接中的, 如果直接断掉副作用太大. 所以我在HHSocketRequest添加了一个生成取消请求的接口, 如果某个API对应的操作很重要, 那么在收到HHNetworkTaskErrorCanceled这个errorCode的时候这个API就需要生成相应的取消操作告诉服务器取消上次的操作, 反之, 如果这个API仅仅是好友列表, 用户信息之类的展示接口那就什么都不用做, 当响应数据返回并走到Task.completeWithResponseData:error:的方法时会直接忽略这个返回结果.
自定义任务的超时处理也很简单(也就是ping-pong), 在任务发起后开启一个timer, 规定时间内返回就取消timer, 否则timer到点后就自动取消任务并返回一个HHNetworkTaskErrorTimeOut错误码.
3.多服务器的切换
这块的处理和HTTP篇的处理一样, 只是多了一个多次连接无果后也进行服务器切换.
四.合理的使用请求派发器
因为我们的Task初始化方法变了, 对应的APIConfiguration自然也就跟着变化一下, 除此之外像APIManager, TaskGroup, APIRecorder一切使用逻辑和规范都和HTTP篇一样, 不做赘述.
本文中很多逻辑都在HTTP篇描述过, 如果有没看过的朋友可以先看看HTTP篇再看本文, 会轻松很多. [HTTP篇链接]
总结
HHSocket: 负责网络通信前的连接操作, 连接发生在子线程中保证UI的流程, 内部实现自动重连操作, 对外暴露切换服务器的接口.
HHSocketRequest: 网络请求的描述类, 对外暴露生成网络请求的接口, 内部管理本地请求和服务器请求的序列号, 生成的网络请求会输出一个格式化好的请求数据包.
HHDataFormatter: 数据序列化的实现类, 定义各个接口保证可读性和拓展性.
HHSocketTask: 网络任务的描述类, 内部负责请求状态的管理, 请求结果的回调和格式化, 对外暴露任务的派发和取消接口.
HHSocketClient: 网络请求的派发器, 这里会记录每一个服役中的任务, 并在必要的时候切换服务器.
写在最后
TCP本身是个极大的话题, 上面洋洋洒洒写了这么多其实只说到了些基本皮毛. 作为客户端的我们本就比服务端要轻松百倍, 又有Socket作为TCP的接口为我们提供了很方便的方式建立自己的TCP应用, 在此基础上还有开源社区的强大工具提供支持, 小子我才有可能实现一套自己的TCP框架, 当然, 现在的框架还很简易, 但聊胜于无, 也算是一次青春的尝试. 总之, 我会继续努力的(认真脸)!
能力一般, 水平有限, 希望此文可以帮到那些刚接触iOS长连接编程无从下手的码友们...